pkgdb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pkgdb/__init__.py ADDED
@@ -0,0 +1,1377 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pkgdb - Track PyPI package download statistics.
4
+
5
+ Reads published packages from packages.yml, fetches download statistics
6
+ via pypistats, stores data in SQLite, and generates HTML reports.
7
+ """
8
+
9
+ import argparse
10
+ import csv
11
+ import io
12
+ import json
13
+ import math
14
+ import sqlite3
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ import webbrowser
18
+
19
+ import pypistats # type: ignore[import-untyped]
20
+ import yaml
21
+ from typing import Any
22
+
23
+
24
+ DEFAULT_PACKAGES_FILE = "packages.yml"
25
+
26
+
27
+ def get_config_dir() -> Path:
28
+ """Get the pkgdb config directory (~/.pkgdb), creating it if needed."""
29
+ config_dir = Path.home() / ".pkgdb"
30
+ config_dir.mkdir(exist_ok=True)
31
+ return config_dir
32
+
33
+
34
+ DEFAULT_DB_FILE = str(get_config_dir() / "pkg.db")
35
+ DEFAULT_REPORT_FILE = str(get_config_dir() / "report.html")
36
+
37
+
38
+ def get_db_connection(db_path: str) -> sqlite3.Connection:
39
+ """Create and return a database connection."""
40
+ conn = sqlite3.connect(db_path)
41
+ conn.row_factory = sqlite3.Row
42
+ return conn
43
+
44
+
45
+ def init_db(conn: sqlite3.Connection) -> None:
46
+ """Initialize the database schema."""
47
+ conn.execute("""
48
+ CREATE TABLE IF NOT EXISTS package_stats (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ package_name TEXT NOT NULL,
51
+ fetch_date TEXT NOT NULL,
52
+ last_day INTEGER,
53
+ last_week INTEGER,
54
+ last_month INTEGER,
55
+ total INTEGER,
56
+ UNIQUE(package_name, fetch_date)
57
+ )
58
+ """)
59
+ conn.execute("""
60
+ CREATE INDEX IF NOT EXISTS idx_package_name
61
+ ON package_stats(package_name)
62
+ """)
63
+ conn.execute("""
64
+ CREATE INDEX IF NOT EXISTS idx_fetch_date
65
+ ON package_stats(fetch_date)
66
+ """)
67
+ conn.commit()
68
+
69
+
70
+ def load_packages(packages_file: str) -> list[str]:
71
+ """Load published packages from YAML file."""
72
+ with open(packages_file) as f:
73
+ data = yaml.safe_load(f)
74
+ result: list[str] = data.get("published", [])
75
+ return result
76
+
77
+
78
+ def fetch_package_stats(package_name: str) -> dict[str, Any] | None:
79
+ """Fetch download statistics for a package from PyPI."""
80
+ try:
81
+ recent_json = pypistats.recent(package_name, format="json")
82
+ recent_data = json.loads(recent_json)
83
+
84
+ data = recent_data.get("data", {})
85
+ stats: dict[str, Any] = {
86
+ "last_day": data.get("last_day", 0),
87
+ "last_week": data.get("last_week", 0),
88
+ "last_month": data.get("last_month", 0),
89
+ }
90
+
91
+ overall_json = pypistats.overall(package_name, format="json")
92
+ overall_data = json.loads(overall_json)
93
+
94
+ total = 0
95
+ for item in overall_data.get("data", []):
96
+ if item.get("category") == "without_mirrors":
97
+ total = item.get("downloads", 0)
98
+ break
99
+ stats["total"] = total
100
+
101
+ return stats
102
+ except Exception as e:
103
+ print(f" Error fetching stats for {package_name}: {e}")
104
+ return None
105
+
106
+
107
+ def fetch_python_versions(package_name: str) -> list[dict[str, Any]] | None:
108
+ """Fetch download breakdown by Python version for a package."""
109
+ try:
110
+ result = pypistats.python_minor(package_name, format="json")
111
+ data = json.loads(result)
112
+ versions: list[dict[str, Any]] = data.get("data", [])
113
+ # Sort by downloads descending
114
+ return sorted(versions, key=lambda x: x.get("downloads", 0), reverse=True)
115
+ except Exception as e:
116
+ print(f" Error fetching Python versions for {package_name}: {e}")
117
+ return None
118
+
119
+
120
+ def fetch_os_stats(package_name: str) -> list[dict[str, Any]] | None:
121
+ """Fetch download breakdown by operating system for a package."""
122
+ try:
123
+ result = pypistats.system(package_name, format="json")
124
+ data = json.loads(result)
125
+ systems: list[dict[str, Any]] = data.get("data", [])
126
+ # Sort by downloads descending
127
+ return sorted(systems, key=lambda x: x.get("downloads", 0), reverse=True)
128
+ except Exception as e:
129
+ print(f" Error fetching OS stats for {package_name}: {e}")
130
+ return None
131
+
132
+
133
+ def aggregate_env_stats(packages: list[str]) -> dict[str, list[tuple[str, int]]]:
134
+ """Aggregate Python version and OS distribution across all packages.
135
+
136
+ Returns dict with 'python_versions' and 'os_distribution' lists of (name, count) tuples.
137
+ """
138
+ py_totals: dict[str, int] = {}
139
+ os_totals: dict[str, int] = {}
140
+
141
+ for pkg in packages:
142
+ py_data = fetch_python_versions(pkg)
143
+ if py_data:
144
+ for item in py_data:
145
+ version = item.get("category", "unknown")
146
+ if version and version != "null":
147
+ py_totals[version] = py_totals.get(version, 0) + item.get(
148
+ "downloads", 0
149
+ )
150
+
151
+ os_data = fetch_os_stats(pkg)
152
+ if os_data:
153
+ for item in os_data:
154
+ os_name = item.get("category", "unknown")
155
+ if os_name == "null":
156
+ os_name = "Unknown"
157
+ os_totals[os_name] = os_totals.get(os_name, 0) + item.get(
158
+ "downloads", 0
159
+ )
160
+
161
+ # Convert to sorted lists
162
+ py_versions = sorted(py_totals.items(), key=lambda x: x[1], reverse=True)
163
+ os_distribution = sorted(os_totals.items(), key=lambda x: x[1], reverse=True)
164
+
165
+ return {
166
+ "python_versions": py_versions,
167
+ "os_distribution": os_distribution,
168
+ }
169
+
170
+
171
+ def store_stats(
172
+ conn: sqlite3.Connection, package_name: str, stats: dict[str, Any]
173
+ ) -> None:
174
+ """Store package statistics in the database."""
175
+ fetch_date = datetime.now().strftime("%Y-%m-%d")
176
+ conn.execute(
177
+ """
178
+ INSERT OR REPLACE INTO package_stats
179
+ (package_name, fetch_date, last_day, last_week, last_month, total)
180
+ VALUES (?, ?, ?, ?, ?, ?)
181
+ """,
182
+ (
183
+ package_name,
184
+ fetch_date,
185
+ stats.get("last_day"),
186
+ stats.get("last_week"),
187
+ stats.get("last_month"),
188
+ stats.get("total"),
189
+ ),
190
+ )
191
+ conn.commit()
192
+
193
+
194
+ def get_latest_stats(conn: sqlite3.Connection) -> list[dict[str, Any]]:
195
+ """Get the most recent stats for all packages, ordered by total downloads."""
196
+ cursor = conn.execute("""
197
+ SELECT ps.*
198
+ FROM package_stats ps
199
+ INNER JOIN (
200
+ SELECT package_name, MAX(fetch_date) as max_date
201
+ FROM package_stats
202
+ GROUP BY package_name
203
+ ) latest ON ps.package_name = latest.package_name
204
+ AND ps.fetch_date = latest.max_date
205
+ ORDER BY ps.total DESC
206
+ """)
207
+ return [dict(row) for row in cursor.fetchall()]
208
+
209
+
210
+ def get_package_history(
211
+ conn: sqlite3.Connection, package_name: str, limit: int = 30
212
+ ) -> list[dict[str, Any]]:
213
+ """Get historical stats for a specific package, ordered by date descending."""
214
+ cursor = conn.execute(
215
+ """
216
+ SELECT * FROM package_stats
217
+ WHERE package_name = ?
218
+ ORDER BY fetch_date DESC
219
+ LIMIT ?
220
+ """,
221
+ (package_name, limit),
222
+ )
223
+ return [dict(row) for row in cursor.fetchall()]
224
+
225
+
226
+ def get_all_history(
227
+ conn: sqlite3.Connection, limit_per_package: int = 30
228
+ ) -> dict[str, list[dict[str, Any]]]:
229
+ """Get historical stats for all packages, grouped by package name."""
230
+ cursor = conn.execute(
231
+ """
232
+ SELECT * FROM (
233
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY package_name ORDER BY fetch_date DESC) as rn
234
+ FROM package_stats
235
+ ) WHERE rn <= ?
236
+ ORDER BY package_name, fetch_date ASC
237
+ """,
238
+ (limit_per_package,),
239
+ )
240
+
241
+ history: dict[str, list[dict[str, Any]]] = {}
242
+ for row in cursor.fetchall():
243
+ row_dict = dict(row)
244
+ pkg = row_dict["package_name"]
245
+ if pkg not in history:
246
+ history[pkg] = []
247
+ history[pkg].append(row_dict)
248
+ return history
249
+
250
+
251
+ def calculate_growth(current: int | None, previous: int | None) -> float | None:
252
+ """Calculate percentage growth between two values."""
253
+ if previous is None or previous == 0:
254
+ return None
255
+ if current is None:
256
+ return None
257
+ return ((current - previous) / previous) * 100
258
+
259
+
260
+ def get_stats_with_growth(conn: sqlite3.Connection) -> list[dict[str, Any]]:
261
+ """Get latest stats with week-over-week and month-over-month growth metrics."""
262
+ stats = get_latest_stats(conn)
263
+
264
+ for s in stats:
265
+ pkg = s["package_name"]
266
+ history = get_package_history(conn, pkg, limit=31)
267
+
268
+ # Find stats from ~7 days ago and ~30 days ago
269
+ week_ago = None
270
+ month_ago = None
271
+
272
+ for h in history[1:]: # Skip the first (current) entry
273
+ days_diff = (
274
+ datetime.strptime(s["fetch_date"], "%Y-%m-%d")
275
+ - datetime.strptime(h["fetch_date"], "%Y-%m-%d")
276
+ ).days
277
+ if week_ago is None and days_diff >= 7:
278
+ week_ago = h
279
+ if month_ago is None and days_diff >= 28:
280
+ month_ago = h
281
+ break
282
+
283
+ # Calculate growth
284
+ s["week_growth"] = calculate_growth(
285
+ s["last_month"], week_ago["last_month"] if week_ago else None
286
+ )
287
+ s["month_growth"] = calculate_growth(
288
+ s["total"], month_ago["total"] if month_ago else None
289
+ )
290
+
291
+ return stats
292
+
293
+
294
+ def make_sparkline(values: list[int], width: int = 7) -> str:
295
+ """Generate an ASCII sparkline from a list of values."""
296
+ if not values:
297
+ return " " * width
298
+
299
+ # Use last 'width' values
300
+ values = values[-width:]
301
+
302
+ # Pad with zeros if not enough values
303
+ if len(values) < width:
304
+ values = [0] * (width - len(values)) + values
305
+
306
+ blocks = " _.,:-=+*#"
307
+ min_val = min(values)
308
+ max_val = max(values)
309
+
310
+ if max_val == min_val:
311
+ return blocks[4] * width
312
+
313
+ sparkline = ""
314
+ for v in values:
315
+ idx = int((v - min_val) / (max_val - min_val) * (len(blocks) - 1))
316
+ sparkline += blocks[idx]
317
+
318
+ return sparkline
319
+
320
+
321
+ def export_csv(stats: list[dict[str, Any]], output: io.StringIO | None = None) -> str:
322
+ """Export stats to CSV format."""
323
+ if output is None:
324
+ output = io.StringIO()
325
+
326
+ writer = csv.writer(output)
327
+ writer.writerow(
328
+ [
329
+ "rank",
330
+ "package_name",
331
+ "total",
332
+ "last_month",
333
+ "last_week",
334
+ "last_day",
335
+ "fetch_date",
336
+ ]
337
+ )
338
+
339
+ for i, s in enumerate(stats, 1):
340
+ writer.writerow(
341
+ [
342
+ i,
343
+ s["package_name"],
344
+ s.get("total") or 0,
345
+ s.get("last_month") or 0,
346
+ s.get("last_week") or 0,
347
+ s.get("last_day") or 0,
348
+ s.get("fetch_date", ""),
349
+ ]
350
+ )
351
+
352
+ return output.getvalue()
353
+
354
+
355
+ def export_json(stats: list[dict[str, Any]]) -> str:
356
+ """Export stats to JSON format."""
357
+ export_data = {
358
+ "generated": datetime.now().isoformat(),
359
+ "packages": [
360
+ {
361
+ "rank": i,
362
+ "name": s["package_name"],
363
+ "total": s.get("total") or 0,
364
+ "last_month": s.get("last_month") or 0,
365
+ "last_week": s.get("last_week") or 0,
366
+ "last_day": s.get("last_day") or 0,
367
+ "fetch_date": s.get("fetch_date", ""),
368
+ }
369
+ for i, s in enumerate(stats, 1)
370
+ ],
371
+ }
372
+ return json.dumps(export_data, indent=2)
373
+
374
+
375
+ def export_markdown(stats: list[dict[str, Any]]) -> str:
376
+ """Export stats to Markdown table format."""
377
+ lines = [
378
+ "| Rank | Package | Total | Month | Week | Day |",
379
+ "|------|---------|------:|------:|-----:|----:|",
380
+ ]
381
+
382
+ for i, s in enumerate(stats, 1):
383
+ lines.append(
384
+ f"| {i} | {s['package_name']} | {s.get('total') or 0:,} | "
385
+ f"{s.get('last_month') or 0:,} | {s.get('last_week') or 0:,} | "
386
+ f"{s.get('last_day') or 0:,} |"
387
+ )
388
+
389
+ return "\n".join(lines)
390
+
391
+
392
+ def make_svg_pie_chart(
393
+ data: list[tuple[str, int]], chart_id: str, size: int = 200
394
+ ) -> str:
395
+ """Generate an SVG pie chart."""
396
+ if not data:
397
+ return ""
398
+
399
+ total = sum(v for _, v in data)
400
+ if total == 0:
401
+ return "<p>No data available.</p>"
402
+
403
+ # Limit to top 6 items, group rest as "Other"
404
+ if len(data) > 6:
405
+ top_data = data[:5]
406
+ other_total = sum(v for _, v in data[5:])
407
+ if other_total > 0:
408
+ top_data.append(("Other", other_total))
409
+ data = top_data
410
+
411
+ cx, cy = size // 2, size // 2
412
+ radius = size // 2 - 10
413
+ legend_width = 150
414
+ total_width = size + legend_width
415
+
416
+ svg_parts = [
417
+ f'<svg id="{chart_id}" viewBox="0 0 {total_width} {size}" '
418
+ f'style="width:100%;max-width:{total_width}px;height:auto;font-family:system-ui,sans-serif;font-size:11px;">'
419
+ ]
420
+
421
+ start_angle: float = 0
422
+ for i, (name, value) in enumerate(data):
423
+ if value == 0:
424
+ continue
425
+ pct = value / total
426
+ angle = pct * 360
427
+ end_angle = start_angle + angle
428
+
429
+ # Calculate arc path
430
+ start_rad = math.radians(start_angle - 90)
431
+ end_rad = math.radians(end_angle - 90)
432
+
433
+ x1 = cx + radius * math.cos(start_rad)
434
+ y1 = cy + radius * math.sin(start_rad)
435
+ x2 = cx + radius * math.cos(end_rad)
436
+ y2 = cy + radius * math.sin(end_rad)
437
+
438
+ large_arc = 1 if angle > 180 else 0
439
+ hue = (i * 360 // len(data)) % 360
440
+
441
+ path = f"M {cx} {cy} L {x1:.1f} {y1:.1f} A {radius} {radius} 0 {large_arc} 1 {x2:.1f} {y2:.1f} Z"
442
+ svg_parts.append(f'<path d="{path}" fill="hsl({hue}, 70%, 50%)"/>')
443
+
444
+ # Legend item
445
+ ly = 20 + i * 25
446
+ svg_parts.append(
447
+ f'<rect x="{size + 10}" y="{ly - 8}" width="12" height="12" fill="hsl({hue}, 70%, 50%)"/>'
448
+ )
449
+ svg_parts.append(
450
+ f'<text x="{size + 28}" y="{ly}" fill="#333">{name} ({pct * 100:.1f}%)</text>'
451
+ )
452
+
453
+ start_angle = end_angle
454
+
455
+ svg_parts.append("</svg>")
456
+ return "\n".join(svg_parts)
457
+
458
+
459
+ def generate_html_report(
460
+ stats: list[dict[str, Any]],
461
+ output_file: str,
462
+ history: dict[str, list[dict[str, Any]]] | None = None,
463
+ packages: list[str] | None = None,
464
+ env_summary: dict[str, list[tuple[str, int]]] | None = None,
465
+ ) -> None:
466
+ """Generate a self-contained HTML report with inline SVG charts.
467
+
468
+ Args:
469
+ stats: List of package statistics
470
+ output_file: Path to write HTML file
471
+ history: Historical data for time-series chart
472
+ packages: List of package names (for fetching env data if env_summary not provided)
473
+ env_summary: Pre-fetched Python version and OS summary data
474
+ """
475
+ if not stats:
476
+ print("No statistics available to generate report.")
477
+ return
478
+
479
+ def make_svg_bar_chart(
480
+ data: list[tuple[str, int]], title: str, chart_id: str
481
+ ) -> str:
482
+ """Generate an SVG bar chart."""
483
+ if not data:
484
+ return ""
485
+
486
+ max_val = max(v for _, v in data) or 1
487
+ bar_height = 28
488
+ bar_gap = 6
489
+ label_width = 160
490
+ value_width = 80
491
+ chart_width = 700
492
+ bar_area_width = chart_width - label_width - value_width
493
+ chart_height = len(data) * (bar_height + bar_gap) + 20
494
+
495
+ svg_parts = [
496
+ f'<svg id="{chart_id}" viewBox="0 0 {chart_width} {chart_height}" '
497
+ f'style="width:100%;max-width:{chart_width}px;height:auto;font-family:system-ui,sans-serif;font-size:12px;">'
498
+ ]
499
+
500
+ for i, (name, value) in enumerate(data):
501
+ y = i * (bar_height + bar_gap) + 10
502
+ bar_width = (value / max_val) * bar_area_width if max_val > 0 else 0
503
+ hue = (i * 360 // len(data)) % 360
504
+
505
+ # Label
506
+ svg_parts.append(
507
+ f'<text x="{label_width - 8}" y="{y + bar_height // 2 + 4}" '
508
+ f'text-anchor="end" fill="#333">{name}</text>'
509
+ )
510
+ # Bar
511
+ svg_parts.append(
512
+ f'<rect x="{label_width}" y="{y}" width="{bar_width:.1f}" '
513
+ f'height="{bar_height}" fill="hsl({hue}, 70%, 50%)" rx="3"/>'
514
+ )
515
+ # Value
516
+ svg_parts.append(
517
+ f'<text x="{label_width + bar_area_width + 8}" y="{y + bar_height // 2 + 4}" '
518
+ f'fill="#666">{value:,}</text>'
519
+ )
520
+
521
+ svg_parts.append("</svg>")
522
+ return "\n".join(svg_parts)
523
+
524
+ def make_svg_line_chart(
525
+ history_data: dict[str, list[dict[str, Any]]] | None, chart_id: str
526
+ ) -> str:
527
+ """Generate an SVG line chart showing downloads over time."""
528
+ if not history_data:
529
+ return ""
530
+
531
+ # Collect all dates and find date range
532
+ all_dates = set()
533
+ for pkg_history in history_data.values():
534
+ for h in pkg_history:
535
+ all_dates.add(h["fetch_date"])
536
+
537
+ if not all_dates:
538
+ return ""
539
+
540
+ sorted_dates = sorted(all_dates)
541
+ if len(sorted_dates) < 2:
542
+ return "<p>Not enough historical data for time-series chart.</p>"
543
+
544
+ chart_width = 700
545
+ chart_height = 300
546
+ margin = {"top": 20, "right": 120, "bottom": 40, "left": 80}
547
+ plot_width = chart_width - margin["left"] - margin["right"]
548
+ plot_height = chart_height - margin["top"] - margin["bottom"]
549
+
550
+ # Find max value across all packages
551
+ max_val = 0
552
+ for pkg_history in history_data.values():
553
+ for h in pkg_history:
554
+ max_val = max(max_val, h["total"] or 0)
555
+ max_val = max_val or 1
556
+
557
+ svg_parts = [
558
+ f'<svg id="{chart_id}" viewBox="0 0 {chart_width} {chart_height}" '
559
+ f'style="width:100%;max-width:{chart_width}px;height:auto;font-family:system-ui,sans-serif;font-size:11px;">'
560
+ ]
561
+
562
+ # Draw axes
563
+ svg_parts.append(
564
+ f'<line x1="{margin["left"]}" y1="{margin["top"]}" '
565
+ f'x2="{margin["left"]}" y2="{chart_height - margin["bottom"]}" '
566
+ f'stroke="#ccc" stroke-width="1"/>'
567
+ )
568
+ svg_parts.append(
569
+ f'<line x1="{margin["left"]}" y1="{chart_height - margin["bottom"]}" '
570
+ f'x2="{chart_width - margin["right"]}" y2="{chart_height - margin["bottom"]}" '
571
+ f'stroke="#ccc" stroke-width="1"/>'
572
+ )
573
+
574
+ # Draw Y-axis labels
575
+ for i in range(5):
576
+ y_val = max_val * (4 - i) / 4
577
+ y_pos = margin["top"] + (i * plot_height / 4)
578
+ svg_parts.append(
579
+ f'<text x="{margin["left"] - 8}" y="{y_pos + 4}" '
580
+ f'text-anchor="end" fill="#666">{int(y_val):,}</text>'
581
+ )
582
+ svg_parts.append(
583
+ f'<line x1="{margin["left"]}" y1="{y_pos}" '
584
+ f'x2="{chart_width - margin["right"]}" y2="{y_pos}" '
585
+ f'stroke="#eee" stroke-width="1"/>'
586
+ )
587
+
588
+ # Draw X-axis labels (show first, middle, last dates)
589
+ date_positions = [0, len(sorted_dates) // 2, len(sorted_dates) - 1]
590
+ for idx in date_positions:
591
+ if idx < len(sorted_dates):
592
+ x_pos = (
593
+ margin["left"] + (idx / max(1, len(sorted_dates) - 1)) * plot_width
594
+ )
595
+ svg_parts.append(
596
+ f'<text x="{x_pos}" y="{chart_height - margin["bottom"] + 16}" '
597
+ f'text-anchor="middle" fill="#666">{sorted_dates[idx]}</text>'
598
+ )
599
+
600
+ # Draw lines for each package (top 5 by total)
601
+ top_packages = sorted(
602
+ history_data.keys(),
603
+ key=lambda p: max((h["total"] or 0) for h in history_data[p]),
604
+ reverse=True,
605
+ )[:5]
606
+
607
+ for pkg_idx, pkg in enumerate(top_packages):
608
+ pkg_history = sorted(history_data[pkg], key=lambda h: h["fetch_date"])
609
+ hue = (pkg_idx * 360 // len(top_packages)) % 360
610
+ color = f"hsl({hue}, 70%, 50%)"
611
+
612
+ # Build path
613
+ points = []
614
+ for h in pkg_history:
615
+ date_idx = sorted_dates.index(h["fetch_date"])
616
+ x = (
617
+ margin["left"]
618
+ + (date_idx / max(1, len(sorted_dates) - 1)) * plot_width
619
+ )
620
+ y = (
621
+ margin["top"]
622
+ + plot_height
623
+ - ((h["total"] or 0) / max_val) * plot_height
624
+ )
625
+ points.append(f"{x:.1f},{y:.1f}")
626
+
627
+ if points:
628
+ svg_parts.append(
629
+ f'<polyline points="{" ".join(points)}" '
630
+ f'fill="none" stroke="{color}" stroke-width="2"/>'
631
+ )
632
+
633
+ # Add label at end
634
+ last_x = (
635
+ margin["left"]
636
+ + ((len(sorted_dates) - 1) / max(1, len(sorted_dates) - 1))
637
+ * plot_width
638
+ )
639
+ last_h = pkg_history[-1]
640
+ last_y = (
641
+ margin["top"]
642
+ + plot_height
643
+ - ((last_h["total"] or 0) / max_val) * plot_height
644
+ )
645
+ svg_parts.append(
646
+ f'<text x="{last_x + 8}" y="{last_y + 4}" fill="{color}">{pkg}</text>'
647
+ )
648
+
649
+ svg_parts.append("</svg>")
650
+ return "\n".join(svg_parts)
651
+
652
+ totals_data = [(s["package_name"], s["total"] or 0) for s in stats]
653
+ month_data = [(s["package_name"], s["last_month"] or 0) for s in stats]
654
+
655
+ totals_chart = make_svg_bar_chart(totals_data, "Total Downloads", "totals-chart")
656
+ month_chart = make_svg_bar_chart(month_data, "Last Month", "month-chart")
657
+ time_series_chart = (
658
+ make_svg_line_chart(history, "time-series-chart") if history else ""
659
+ )
660
+
661
+ # Generate environment summary charts if data available
662
+ py_version_chart = ""
663
+ os_chart = ""
664
+ if env_summary:
665
+ py_data = env_summary.get("python_versions", [])
666
+ if py_data:
667
+ py_version_chart = make_svg_pie_chart(py_data, "py-version-chart", size=200)
668
+ os_data = env_summary.get("os_distribution", [])
669
+ if os_data:
670
+ os_chart = make_svg_pie_chart(os_data, "os-chart", size=200)
671
+
672
+ env_summary_html = ""
673
+ if py_version_chart or os_chart:
674
+ env_summary_html = f"""
675
+ <div class="chart-container">
676
+ <h2>Environment Summary (Aggregated)</h2>
677
+ <div class="pie-charts-row">
678
+ {f'<div class="pie-chart-wrapper"><h3>Python Versions</h3>{py_version_chart}</div>' if py_version_chart else ""}
679
+ {f'<div class="pie-chart-wrapper"><h3>Operating Systems</h3>{os_chart}</div>' if os_chart else ""}
680
+ </div>
681
+ </div>
682
+ """
683
+
684
+ html = f"""<!DOCTYPE html>
685
+ <html lang="en">
686
+ <head>
687
+ <meta charset="UTF-8">
688
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
689
+ <title>PyPI Package Download Statistics</title>
690
+ <style>
691
+ body {{
692
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
693
+ max-width: 1200px;
694
+ margin: 0 auto;
695
+ padding: 20px;
696
+ background: #f5f5f5;
697
+ }}
698
+ h1, h2 {{
699
+ color: #333;
700
+ }}
701
+ .chart-container {{
702
+ background: white;
703
+ border-radius: 8px;
704
+ padding: 20px;
705
+ margin: 20px 0;
706
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
707
+ overflow-x: auto;
708
+ }}
709
+ table {{
710
+ width: 100%;
711
+ border-collapse: collapse;
712
+ background: white;
713
+ border-radius: 8px;
714
+ overflow: hidden;
715
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
716
+ }}
717
+ th, td {{
718
+ padding: 12px;
719
+ text-align: left;
720
+ border-bottom: 1px solid #eee;
721
+ }}
722
+ th {{
723
+ background: #4a90a4;
724
+ color: white;
725
+ }}
726
+ tr:hover {{
727
+ background: #f9f9f9;
728
+ }}
729
+ .number {{
730
+ text-align: right;
731
+ font-family: monospace;
732
+ }}
733
+ .generated {{
734
+ color: #666;
735
+ font-size: 0.9em;
736
+ margin-top: 20px;
737
+ }}
738
+ .pie-charts-row {{
739
+ display: flex;
740
+ flex-wrap: wrap;
741
+ gap: 40px;
742
+ justify-content: flex-start;
743
+ }}
744
+ .pie-chart-wrapper {{
745
+ flex: 0 0 auto;
746
+ }}
747
+ .pie-chart-wrapper h3 {{
748
+ margin: 0 0 10px 0;
749
+ font-size: 14px;
750
+ color: #555;
751
+ }}
752
+ </style>
753
+ </head>
754
+ <body>
755
+ <h1>PyPI Package Download Statistics</h1>
756
+
757
+ <div class="chart-container">
758
+ <h2>Total Downloads by Package</h2>
759
+ {totals_chart}
760
+ </div>
761
+
762
+ <div class="chart-container">
763
+ <h2>Recent Downloads (Last Month)</h2>
764
+ {month_chart}
765
+ </div>
766
+
767
+ {f'<div class="chart-container"><h2>Downloads Over Time (Top 5)</h2>{time_series_chart}</div>' if time_series_chart else ""}
768
+
769
+ {env_summary_html}
770
+
771
+ <h2>Detailed Statistics</h2>
772
+ <table>
773
+ <thead>
774
+ <tr>
775
+ <th>Rank</th>
776
+ <th>Package</th>
777
+ <th class="number">Total</th>
778
+ <th class="number">Last Month</th>
779
+ <th class="number">Last Week</th>
780
+ <th class="number">Last Day</th>
781
+ </tr>
782
+ </thead>
783
+ <tbody>
784
+ """
785
+
786
+ for i, s in enumerate(stats, 1):
787
+ html += f""" <tr>
788
+ <td>{i}</td>
789
+ <td><a href="https://pypi.org/project/{s["package_name"]}/">{s["package_name"]}</a></td>
790
+ <td class="number">{s["total"] or 0:,}</td>
791
+ <td class="number">{s["last_month"] or 0:,}</td>
792
+ <td class="number">{s["last_week"] or 0:,}</td>
793
+ <td class="number">{s["last_day"] or 0:,}</td>
794
+ </tr>
795
+ """
796
+
797
+ html += f""" </tbody>
798
+ </table>
799
+
800
+ <p class="generated">Generated on {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
801
+ </body>
802
+ </html>
803
+ """
804
+
805
+ with open(output_file, "w") as f:
806
+ f.write(html)
807
+ print(f"Report generated: {output_file}")
808
+
809
+
810
+ def generate_package_html_report(
811
+ package: str,
812
+ output_file: str,
813
+ stats: dict[str, Any] | None = None,
814
+ history: list[dict[str, Any]] | None = None,
815
+ ) -> None:
816
+ """Generate a detailed HTML report for a single package.
817
+
818
+ Includes download stats, Python version distribution, and OS breakdown.
819
+ """
820
+ print(f"Fetching detailed stats for {package}...")
821
+
822
+ # Fetch fresh stats from API if not provided
823
+ if stats is None:
824
+ stats = fetch_package_stats(package)
825
+
826
+ if not stats:
827
+ print(f"Could not fetch stats for {package}")
828
+ return
829
+
830
+ # Fetch environment data
831
+ py_versions = fetch_python_versions(package)
832
+ os_stats = fetch_os_stats(package)
833
+
834
+ # Build pie charts
835
+ py_version_chart = ""
836
+ if py_versions:
837
+ py_data = [
838
+ (v.get("category", "unknown"), v.get("downloads", 0))
839
+ for v in py_versions
840
+ if v.get("category") and v.get("category") != "null"
841
+ ]
842
+ py_version_chart = make_svg_pie_chart(py_data, "py-version-chart", size=220)
843
+
844
+ os_chart = ""
845
+ if os_stats:
846
+ os_data = []
847
+ for s in os_stats:
848
+ name = s.get("category", "unknown")
849
+ if name == "null":
850
+ name = "Unknown"
851
+ os_data.append((name, s.get("downloads", 0)))
852
+ os_chart = make_svg_pie_chart(os_data, "os-chart", size=220)
853
+
854
+ # Build history chart if available
855
+ history_chart = ""
856
+ if history and len(history) >= 2:
857
+ # Create line chart from history
858
+ chart_width = 600
859
+ chart_height = 200
860
+ margin = {"top": 20, "right": 20, "bottom": 40, "left": 80}
861
+ plot_width = chart_width - margin["left"] - margin["right"]
862
+ plot_height = chart_height - margin["top"] - margin["bottom"]
863
+
864
+ sorted_history = sorted(history, key=lambda h: h["fetch_date"])
865
+ dates = [h["fetch_date"] for h in sorted_history]
866
+ values = [h["total"] or 0 for h in sorted_history]
867
+ max_val = max(values) or 1
868
+
869
+ svg_parts = [
870
+ f'<svg viewBox="0 0 {chart_width} {chart_height}" '
871
+ f'style="width:100%;max-width:{chart_width}px;height:auto;font-family:system-ui,sans-serif;font-size:11px;">'
872
+ ]
873
+
874
+ # Y-axis
875
+ for i in range(5):
876
+ y_val = max_val * (4 - i) / 4
877
+ y_pos = margin["top"] + (i * plot_height / 4)
878
+ svg_parts.append(
879
+ f'<text x="{margin["left"] - 8}" y="{y_pos + 4}" '
880
+ f'text-anchor="end" fill="#666">{int(y_val):,}</text>'
881
+ )
882
+ svg_parts.append(
883
+ f'<line x1="{margin["left"]}" y1="{y_pos}" '
884
+ f'x2="{chart_width - margin["right"]}" y2="{y_pos}" '
885
+ f'stroke="#eee" stroke-width="1"/>'
886
+ )
887
+
888
+ # X-axis labels
889
+ if len(dates) > 1:
890
+ for idx in [0, len(dates) // 2, len(dates) - 1]:
891
+ x_pos = margin["left"] + (idx / (len(dates) - 1)) * plot_width
892
+ svg_parts.append(
893
+ f'<text x="{x_pos}" y="{chart_height - 10}" '
894
+ f'text-anchor="middle" fill="#666">{dates[idx]}</text>'
895
+ )
896
+
897
+ # Line
898
+ points = []
899
+ for i, val in enumerate(values):
900
+ x = margin["left"] + (i / max(1, len(values) - 1)) * plot_width
901
+ y = margin["top"] + plot_height - (val / max_val) * plot_height
902
+ points.append(f"{x:.1f},{y:.1f}")
903
+
904
+ svg_parts.append(
905
+ f'<polyline points="{" ".join(points)}" '
906
+ f'fill="none" stroke="hsl(200, 70%, 50%)" stroke-width="2"/>'
907
+ )
908
+ svg_parts.append("</svg>")
909
+ history_chart = "\n".join(svg_parts)
910
+
911
+ html = f"""<!DOCTYPE html>
912
+ <html lang="en">
913
+ <head>
914
+ <meta charset="UTF-8">
915
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
916
+ <title>{package} - Download Statistics</title>
917
+ <style>
918
+ body {{
919
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
920
+ max-width: 1000px;
921
+ margin: 0 auto;
922
+ padding: 20px;
923
+ background: #f5f5f5;
924
+ }}
925
+ h1, h2, h3 {{
926
+ color: #333;
927
+ }}
928
+ .chart-container {{
929
+ background: white;
930
+ border-radius: 8px;
931
+ padding: 20px;
932
+ margin: 20px 0;
933
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
934
+ }}
935
+ .stats-grid {{
936
+ display: grid;
937
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
938
+ gap: 20px;
939
+ margin: 20px 0;
940
+ }}
941
+ .stat-card {{
942
+ background: white;
943
+ border-radius: 8px;
944
+ padding: 20px;
945
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
946
+ text-align: center;
947
+ }}
948
+ .stat-value {{
949
+ font-size: 24px;
950
+ font-weight: bold;
951
+ color: #4a90a4;
952
+ }}
953
+ .stat-label {{
954
+ font-size: 12px;
955
+ color: #666;
956
+ margin-top: 5px;
957
+ }}
958
+ .pie-charts-row {{
959
+ display: flex;
960
+ flex-wrap: wrap;
961
+ gap: 40px;
962
+ justify-content: flex-start;
963
+ }}
964
+ .pie-chart-wrapper {{
965
+ flex: 0 0 auto;
966
+ }}
967
+ .pie-chart-wrapper h3 {{
968
+ margin: 0 0 10px 0;
969
+ font-size: 14px;
970
+ color: #555;
971
+ }}
972
+ .generated {{
973
+ color: #666;
974
+ font-size: 0.9em;
975
+ margin-top: 20px;
976
+ }}
977
+ a {{
978
+ color: #4a90a4;
979
+ }}
980
+ </style>
981
+ </head>
982
+ <body>
983
+ <h1>{package}</h1>
984
+ <p><a href="https://pypi.org/project/{package}/">View on PyPI</a></p>
985
+
986
+ <div class="stats-grid">
987
+ <div class="stat-card">
988
+ <div class="stat-value">{stats["total"]:,}</div>
989
+ <div class="stat-label">Total Downloads</div>
990
+ </div>
991
+ <div class="stat-card">
992
+ <div class="stat-value">{stats["last_month"]:,}</div>
993
+ <div class="stat-label">Last Month</div>
994
+ </div>
995
+ <div class="stat-card">
996
+ <div class="stat-value">{stats["last_week"]:,}</div>
997
+ <div class="stat-label">Last Week</div>
998
+ </div>
999
+ <div class="stat-card">
1000
+ <div class="stat-value">{stats["last_day"]:,}</div>
1001
+ <div class="stat-label">Last Day</div>
1002
+ </div>
1003
+ </div>
1004
+
1005
+ {f'<div class="chart-container"><h2>Downloads Over Time</h2>{history_chart}</div>' if history_chart else ""}
1006
+
1007
+ <div class="chart-container">
1008
+ <h2>Environment Distribution</h2>
1009
+ <div class="pie-charts-row">
1010
+ {f'<div class="pie-chart-wrapper"><h3>Python Versions</h3>{py_version_chart}</div>' if py_version_chart else "<p>Python version data not available</p>"}
1011
+ {f'<div class="pie-chart-wrapper"><h3>Operating Systems</h3>{os_chart}</div>' if os_chart else "<p>OS data not available</p>"}
1012
+ </div>
1013
+ </div>
1014
+
1015
+ <p class="generated">Generated on {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
1016
+ </body>
1017
+ </html>
1018
+ """
1019
+
1020
+ with open(output_file, "w") as f:
1021
+ f.write(html)
1022
+ print(f"Report generated: {output_file}")
1023
+
1024
+
1025
+ def cmd_fetch(args: argparse.Namespace) -> None:
1026
+ """Fetch command: download stats and store in database."""
1027
+ packages = load_packages(args.packages)
1028
+ print(f"Loaded {len(packages)} packages from {args.packages}")
1029
+
1030
+ conn = get_db_connection(args.database)
1031
+ init_db(conn)
1032
+
1033
+ for package in packages:
1034
+ print(f"Fetching stats for {package}...")
1035
+ stats = fetch_package_stats(package)
1036
+ if stats:
1037
+ store_stats(conn, package, stats)
1038
+ print(
1039
+ f" Total: {stats['total']:,} | Month: {stats['last_month']:,} | "
1040
+ f"Week: {stats['last_week']:,} | Day: {stats['last_day']:,}"
1041
+ )
1042
+
1043
+ conn.close()
1044
+ print("Done.")
1045
+
1046
+
1047
+ def cmd_report(args: argparse.Namespace) -> None:
1048
+ """Report command: generate HTML report from stored data.
1049
+
1050
+ If a package name is provided, generates a detailed report for that package.
1051
+ Otherwise, generates a summary report for all packages.
1052
+ """
1053
+ conn = get_db_connection(args.database)
1054
+ init_db(conn)
1055
+
1056
+ # Check if single-package report requested
1057
+ package = getattr(args, "package", None)
1058
+
1059
+ if package:
1060
+ # Single package detailed report
1061
+ pkg_history = get_package_history(conn, package, limit=30)
1062
+ conn.close()
1063
+
1064
+ # Find stats in database or fetch fresh
1065
+ pkg_stats: dict[str, Any] | None = None
1066
+ for h in pkg_history:
1067
+ if h["package_name"] == package:
1068
+ pkg_stats = {
1069
+ "total": h["total"],
1070
+ "last_month": h["last_month"],
1071
+ "last_week": h["last_week"],
1072
+ "last_day": h["last_day"],
1073
+ }
1074
+ break
1075
+
1076
+ generate_package_html_report(
1077
+ package, args.output, stats=pkg_stats, history=pkg_history
1078
+ )
1079
+ else:
1080
+ # Summary report for all packages
1081
+ stats = get_latest_stats(conn)
1082
+ all_history = get_all_history(conn, limit_per_package=30)
1083
+ packages = [s["package_name"] for s in stats]
1084
+ conn.close()
1085
+
1086
+ if not stats:
1087
+ print("No data in database. Run 'fetch' first.")
1088
+ return
1089
+
1090
+ # Fetch environment summary (aggregated across all packages)
1091
+ env_summary: dict[str, list[tuple[str, int]]] | None = None
1092
+ if args.env:
1093
+ print("Fetching environment data (this may take a moment)...")
1094
+ env_summary = aggregate_env_stats(packages)
1095
+
1096
+ generate_html_report(stats, args.output, all_history, packages, env_summary)
1097
+
1098
+ print("opening...")
1099
+ webbrowser.open_new_tab(Path(args.output).resolve().as_uri())
1100
+
1101
+
1102
+ def cmd_update(args: argparse.Namespace) -> None:
1103
+ """Sync command: fetch stats then generate report."""
1104
+ cmd_fetch(args)
1105
+ cmd_report(args)
1106
+
1107
+
1108
+ def cmd_list(args: argparse.Namespace) -> None:
1109
+ """List command: show stored statistics."""
1110
+ conn = get_db_connection(args.database)
1111
+ init_db(conn)
1112
+
1113
+ stats = get_stats_with_growth(conn)
1114
+ history = get_all_history(conn, limit_per_package=14)
1115
+ conn.close()
1116
+
1117
+ if not stats:
1118
+ print("No data in database. Run 'fetch' first.")
1119
+ return
1120
+
1121
+ print(
1122
+ f"{'Rank':<5} {'Package':<20} {'Total':>12} {'Month':>10} "
1123
+ f"{'Week':>8} {'Day':>7} {'Trend':>7} {'Growth':>8}"
1124
+ )
1125
+ print("-" * 82)
1126
+
1127
+ for i, s in enumerate(stats, 1):
1128
+ pkg = s["package_name"]
1129
+ pkg_history = history.get(pkg, [])
1130
+ totals = [h["total"] or 0 for h in pkg_history]
1131
+ sparkline = make_sparkline(totals, width=7)
1132
+
1133
+ # Format growth
1134
+ growth_str = ""
1135
+ if s.get("month_growth") is not None:
1136
+ g = s["month_growth"]
1137
+ sign = "+" if g >= 0 else ""
1138
+ growth_str = f"{sign}{g:.1f}%"
1139
+
1140
+ print(
1141
+ f"{i:<5} {pkg:<20} {s['total'] or 0:>12,} "
1142
+ f"{s['last_month'] or 0:>10,} {s['last_week'] or 0:>8,} "
1143
+ f"{s['last_day'] or 0:>7,} {sparkline:>7} {growth_str:>8}"
1144
+ )
1145
+
1146
+
1147
+ def cmd_history(args: argparse.Namespace) -> None:
1148
+ """History command: show historical stats for a package."""
1149
+ conn = get_db_connection(args.database)
1150
+ init_db(conn)
1151
+
1152
+ history = get_package_history(conn, args.package, limit=args.limit)
1153
+ conn.close()
1154
+
1155
+ if not history:
1156
+ print(f"No data found for package '{args.package}'.")
1157
+ return
1158
+
1159
+ print(f"Historical stats for {args.package}")
1160
+ print(f"{'Date':<12} {'Total':>12} {'Month':>10} {'Week':>10} {'Day':>8}")
1161
+ print("-" * 55)
1162
+
1163
+ # Reverse to show oldest first
1164
+ for h in reversed(history):
1165
+ print(
1166
+ f"{h['fetch_date']:<12} {h['total'] or 0:>12,} "
1167
+ f"{h['last_month'] or 0:>10,} {h['last_week'] or 0:>10,} "
1168
+ f"{h['last_day'] or 0:>8,}"
1169
+ )
1170
+
1171
+
1172
+ def cmd_export(args: argparse.Namespace) -> None:
1173
+ """Export command: export stats in various formats."""
1174
+ conn = get_db_connection(args.database)
1175
+ init_db(conn)
1176
+
1177
+ stats = get_latest_stats(conn)
1178
+ conn.close()
1179
+
1180
+ if not stats:
1181
+ print("No data in database. Run 'fetch' first.")
1182
+ return
1183
+
1184
+ # Generate export based on format
1185
+ if args.format == "csv":
1186
+ output = export_csv(stats)
1187
+ elif args.format == "json":
1188
+ output = export_json(stats)
1189
+ elif args.format == "markdown" or args.format == "md":
1190
+ output = export_markdown(stats)
1191
+ else:
1192
+ print(f"Unknown format: {args.format}")
1193
+ return
1194
+
1195
+ # Write to file or stdout
1196
+ if args.output:
1197
+ with open(args.output, "w") as f:
1198
+ f.write(output)
1199
+ print(f"Exported to {args.output}")
1200
+ else:
1201
+ print(output)
1202
+
1203
+
1204
+ def cmd_stats(args: argparse.Namespace) -> None:
1205
+ """Stats command: show detailed statistics for a package."""
1206
+ package = args.package
1207
+ print(f"Fetching detailed stats for {package}...\n")
1208
+
1209
+ # Fetch basic stats
1210
+ basic = fetch_package_stats(package)
1211
+ if basic:
1212
+ print("=== Download Summary ===")
1213
+ print(f" Total: {basic['total']:>12,}")
1214
+ print(f" Last month: {basic['last_month']:>12,}")
1215
+ print(f" Last week: {basic['last_week']:>12,}")
1216
+ print(f" Last day: {basic['last_day']:>12,}")
1217
+ print()
1218
+
1219
+ # Fetch Python version breakdown
1220
+ py_versions = fetch_python_versions(package)
1221
+ if py_versions:
1222
+ print("=== Python Version Distribution ===")
1223
+ total_downloads = sum(v.get("downloads", 0) for v in py_versions)
1224
+ for v in py_versions[:10]: # Top 10
1225
+ version = v.get("category", "unknown")
1226
+ downloads = v.get("downloads", 0)
1227
+ pct = (downloads / total_downloads * 100) if total_downloads > 0 else 0
1228
+ bar = "#" * int(pct / 2)
1229
+ print(f" Python {version:<6} {downloads:>12,} ({pct:>5.1f}%) {bar}")
1230
+ print()
1231
+
1232
+ # Fetch OS breakdown
1233
+ os_stats = fetch_os_stats(package)
1234
+ if os_stats:
1235
+ print("=== Operating System Distribution ===")
1236
+ total_downloads = sum(s.get("downloads", 0) for s in os_stats)
1237
+ for s in os_stats:
1238
+ os_name = s.get("category", "unknown")
1239
+ if os_name == "null":
1240
+ os_name = "Unknown"
1241
+ downloads = s.get("downloads", 0)
1242
+ pct = (downloads / total_downloads * 100) if total_downloads > 0 else 0
1243
+ bar = "#" * int(pct / 2)
1244
+ print(f" {os_name:<10} {downloads:>12,} ({pct:>5.1f}%) {bar}")
1245
+ print()
1246
+
1247
+
1248
+ def main() -> None:
1249
+ parser = argparse.ArgumentParser(
1250
+ description="Track PyPI package download statistics.",
1251
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1252
+ )
1253
+ parser.add_argument(
1254
+ "-d",
1255
+ "--database",
1256
+ default=DEFAULT_DB_FILE,
1257
+ help=f"SQLite database file (default: {DEFAULT_DB_FILE})",
1258
+ )
1259
+ parser.add_argument(
1260
+ "-p",
1261
+ "--packages",
1262
+ default=DEFAULT_PACKAGES_FILE,
1263
+ help=f"Packages YAML file (default: {DEFAULT_PACKAGES_FILE})",
1264
+ )
1265
+
1266
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
1267
+
1268
+ # fetch command
1269
+ fetch_parser = subparsers.add_parser(
1270
+ "fetch",
1271
+ help="Fetch download statistics from PyPI",
1272
+ )
1273
+ fetch_parser.set_defaults(func=cmd_fetch)
1274
+
1275
+ # report command
1276
+ report_parser = subparsers.add_parser(
1277
+ "report",
1278
+ help="Generate HTML report with charts",
1279
+ )
1280
+ report_parser.add_argument(
1281
+ "package",
1282
+ nargs="?",
1283
+ help="Package name for detailed single-package report (optional)",
1284
+ )
1285
+ report_parser.add_argument(
1286
+ "-o",
1287
+ "--output",
1288
+ default=DEFAULT_REPORT_FILE,
1289
+ help=f"Output HTML file (default: {DEFAULT_REPORT_FILE})",
1290
+ )
1291
+ report_parser.add_argument(
1292
+ "-e",
1293
+ "--env",
1294
+ action="store_true",
1295
+ help="Include environment summary (Python versions, OS) in report",
1296
+ )
1297
+ report_parser.set_defaults(func=cmd_report)
1298
+
1299
+ # list command
1300
+ list_parser = subparsers.add_parser(
1301
+ "list",
1302
+ help="List stored statistics",
1303
+ )
1304
+ list_parser.set_defaults(func=cmd_list)
1305
+
1306
+ # history command
1307
+ history_parser = subparsers.add_parser(
1308
+ "history",
1309
+ help="Show historical stats for a package",
1310
+ )
1311
+ history_parser.add_argument(
1312
+ "package",
1313
+ help="Package name to show history for",
1314
+ )
1315
+ history_parser.add_argument(
1316
+ "-n",
1317
+ "--limit",
1318
+ type=int,
1319
+ default=30,
1320
+ help="Number of days to show (default: 30)",
1321
+ )
1322
+ history_parser.set_defaults(func=cmd_history)
1323
+
1324
+ # export command
1325
+ export_parser = subparsers.add_parser(
1326
+ "export",
1327
+ help="Export stats in various formats (csv, json, markdown)",
1328
+ )
1329
+ export_parser.add_argument(
1330
+ "-f",
1331
+ "--format",
1332
+ choices=["csv", "json", "markdown", "md"],
1333
+ default="csv",
1334
+ help="Export format (default: csv)",
1335
+ )
1336
+ export_parser.add_argument(
1337
+ "-o",
1338
+ "--output",
1339
+ help="Output file (default: stdout)",
1340
+ )
1341
+ export_parser.set_defaults(func=cmd_export)
1342
+
1343
+ # stats command
1344
+ stats_parser = subparsers.add_parser(
1345
+ "stats",
1346
+ help="Show detailed stats for a package (Python versions, OS breakdown)",
1347
+ )
1348
+ stats_parser.add_argument(
1349
+ "package",
1350
+ help="Package name to show detailed stats for",
1351
+ )
1352
+ stats_parser.set_defaults(func=cmd_stats)
1353
+
1354
+ # update command
1355
+ update_parser = subparsers.add_parser(
1356
+ "update",
1357
+ help="Fetch stats and generate report",
1358
+ )
1359
+ update_parser.add_argument(
1360
+ "-o",
1361
+ "--output",
1362
+ default=DEFAULT_REPORT_FILE,
1363
+ help=f"Output HTML file (default: {DEFAULT_REPORT_FILE})",
1364
+ )
1365
+ update_parser.set_defaults(func=cmd_update)
1366
+
1367
+ args = parser.parse_args()
1368
+
1369
+ if args.command is None:
1370
+ parser.print_help()
1371
+ return
1372
+
1373
+ args.func(args)
1374
+
1375
+
1376
+ if __name__ == "__main__":
1377
+ main()