Coverage for app/views/routes/packages.py: 0%
101 statements
« prev ^ index » next coverage.py v7.6.10, created at 2026-06-04 22:29 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2026-06-04 22:29 +0000
1from typing import Annotated, Literal
3from fastapi import APIRouter, BackgroundTasks, Form, Header, Request
4from fastapi.responses import HTMLResponse
6from app.analytics.posthog import capture_package_requested_events
7from app.chart import generate_altair_colors, generate_chart
8from app.core.logger import logger
9from app.core.templates import templates
10from app.models import TimeRangeValidValues
11from app.normalization import NormalizedPackageName
12from app.utils import (
13 extract_last_script_tag,
14 generate_hx_push_url,
15 parse_query_params,
16 validate_package,
17)
19router: APIRouter = APIRouter()
22@router.get("/package-list", response_class=HTMLResponse)
23async def get_package_list(
24 request: Request,
25 url: Annotated[str, Header(alias="HX-Current-URL")],
26) -> HTMLResponse:
27 logger.info(f"Fetching package list from URL: {url}")
28 query_params = parse_query_params(url)
30 if query_params.error:
31 logger.warning(query_params.error)
32 return HTMLResponse(status_code=422, content=query_params.error)
34 package_data = []
35 if query_params.packages:
36 colors = generate_altair_colors(len(query_params.packages))
37 for package, color in zip(query_params.packages, colors):
38 package_data.append({"name": package, "color": color})
40 return templates.TemplateResponse(
41 request=request,
42 name="fragments/package_list.html",
43 context={"package_data": package_data},
44 )
47@router.post("/package-list", response_class=HTMLResponse)
48async def create_package(
49 request: Request,
50 package_name: Annotated[NormalizedPackageName, Form()],
51 url: Annotated[str, Header(alias="HX-Current-URL")],
52) -> HTMLResponse:
53 logger.info(f"Attempting to add package: {package_name}")
54 query_params = parse_query_params(url)
56 if query_params.error:
57 logger.warning(query_params.error)
58 return HTMLResponse(status_code=422, content=query_params.error)
60 if package_name in query_params.packages:
61 logger.warning(f"Duplicate package attempt: {package_name}")
62 return HTMLResponse(
63 status_code=409, content=f"'{package_name}' already selected"
64 )
66 if not validate_package(package_name):
67 logger.warning(f"Invalid package name: {package_name} - not found on PyPI")
68 return HTMLResponse(
69 status_code=422, content=f"'{package_name}' not found on PyPI"
70 )
72 query_params.packages.append(package_name)
73 num_of_packages = len(query_params.packages)
74 logger.info(
75 f"Successfully added package: {package_name}. Total packages: {num_of_packages}"
76 )
78 if len(query_params.packages) == 1:
79 query_params.show_percentage = None
81 return templates.TemplateResponse(
82 request=request,
83 name="fragments/package_line_item.html",
84 context={
85 "package_name": package_name,
86 "color": generate_altair_colors(num_of_packages)[-1],
87 },
88 headers={"HX-Push-Url": generate_hx_push_url(query_params)},
89 )
92@router.delete("/package-list", response_class=HTMLResponse)
93async def delete_package(
94 request: Request,
95 package_name: NormalizedPackageName,
96 url: Annotated[str, Header(alias="HX-Current-URL")],
97) -> HTMLResponse:
98 logger.info(f"Attempting to delete package: {package_name}")
99 query_params = parse_query_params(url)
101 if query_params.error:
102 logger.warning(query_params.error)
103 return HTMLResponse(status_code=422, content=query_params.error)
105 if package_name in query_params.packages:
106 query_params.packages.remove(package_name)
107 logger.info(f"Successfully removed package: {package_name}")
108 else:
109 logger.warning(f"Package {package_name} not found in current URL state")
111 colors = generate_altair_colors(len(query_params.packages))
113 package_data = [
114 {"name": package, "color": color}
115 for package, color in zip(query_params.packages, colors)
116 ]
118 if len(query_params.packages) == 1:
119 query_params.show_percentage = None
121 return templates.TemplateResponse(
122 request=request,
123 name="fragments/package_list.html",
124 context={"package_data": package_data},
125 headers={"HX-Push-Url": generate_hx_push_url(query_params)},
126 )
129@router.get("/packages-graph", response_class=HTMLResponse)
130async def get_graph(
131 request: Request,
132 background_tasks: BackgroundTasks,
133 url: Annotated[str, Header(alias="HX-Current-URL")],
134 time_range: TimeRangeValidValues | None = None,
135 show_percentage: Literal["on"] | None = None,
136) -> HTMLResponse:
137 query_params = parse_query_params(url)
139 if query_params.error:
140 logger.warning(query_params.error)
141 return HTMLResponse(status_code=422, content=query_params.error)
143 theme = request.cookies.get("theme", "light")
144 chart_html = ""
145 last_script_tag = ""
147 if time_range is not None:
148 query_params.time_range.value = time_range
150 if (
151 show_percentage is not None
152 or request.headers["HX-Trigger"] == "show-percentage"
153 ):
154 query_params.show_percentage = show_percentage
156 if len(query_params.packages) == 1:
157 query_params.show_percentage = None
159 background_tasks.add_task(
160 capture_package_requested_events,
161 packages=query_params.packages,
162 time_range=query_params.time_range.value,
163 show_percentage=query_params.show_percentage == "on",
164 hx_trigger=request.headers.get("HX-Trigger"),
165 request_id=request.headers.get("X-Request-ID"),
166 )
168 if query_params.packages:
169 chart_html = generate_chart(query_params, theme).to_html(fullhtml=False)
170 last_script_tag = extract_last_script_tag(chart_html) or ""
172 headers = {}
174 if request.headers["HX-Trigger"] in ["time-range", "show-percentage"]:
175 headers["HX-Push-Url"] = generate_hx_push_url(query_params)
177 return templates.TemplateResponse(
178 request=request,
179 name="fragments/chart.html",
180 context={
181 "chart": last_script_tag,
182 "query_params": query_params,
183 },
184 headers=headers,
185 )
188@router.get("/embed", response_class=HTMLResponse)
189async def get_embed(
190 request: Request,
191 time_range: TimeRangeValidValues | None = None,
192 theme: Literal["light", "dark"] | None = None,
193 show_percentage: Literal["on"] | None = None,
194) -> HTMLResponse:
195 """Endpoint for embedded charts that can be used in iframes."""
196 query_params = parse_query_params(str(request.url))
198 if query_params.error:
199 logger.warning(query_params.error)
200 return HTMLResponse(status_code=422, content=query_params.error)
202 if not theme:
203 theme = request.cookies.get("theme", "light")
205 if time_range is not None:
206 query_params.time_range.value = time_range
208 if show_percentage is not None:
209 query_params.show_percentage = show_percentage
211 if not query_params.packages:
212 return HTMLResponse(content="No packages selected")
214 chart_html = generate_chart(query_params, theme).to_html(fullhtml=True)
216 attribution_style = """
217 <style>
218 .pypacktrends-attribution {
219 text-align: center;
220 padding: 8px;
221 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
222 font-size: 12px;
223 }
224 .pypacktrends-attribution a {
225 color: #666;
226 text-decoration: none;
227 }
228 .pypacktrends-attribution a:hover {
229 text-decoration: underline;
230 }
231 </style>
232 """
234 attribution_html = f"""
235 <div class="pypacktrends-attribution">
236 <a href="{str(request.url).replace("/embed", "")}" target="_blank">
237 View on pypacktrends.com
238 </a>
239 </div>
240 """
242 modified_html = chart_html.replace(
243 "<body>", f"<body>{attribution_style}{attribution_html}"
244 )
246 return HTMLResponse(content=modified_html)