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 +1377 -0
- pkgdb/__main__.py +6 -0
- pkgdb-0.1.0.dist-info/METADATA +169 -0
- pkgdb-0.1.0.dist-info/RECORD +7 -0
- pkgdb-0.1.0.dist-info/WHEEL +4 -0
- pkgdb-0.1.0.dist-info/entry_points.txt +3 -0
- pkgdb-0.1.0.dist-info/licenses/LICENSE +15 -0
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()
|