pypi-tracker 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.
@@ -0,0 +1,22 @@
1
+ """Collect, permanently preserve, aggregate and export PyPI download statistics."""
2
+
3
+ from .aggregation import find_gaps, monthly, rolling_window, summarize, weekly, yearly
4
+ from .config import ReportSettings
5
+ from .errors import ConfigError, HistoryError, PartialFailure, PyPIStatsError, TrackerError
6
+ from .tracker import Tracker
7
+
8
+ __all__ = [
9
+ "ConfigError",
10
+ "HistoryError",
11
+ "PartialFailure",
12
+ "PyPIStatsError",
13
+ "ReportSettings",
14
+ "Tracker",
15
+ "TrackerError",
16
+ "find_gaps",
17
+ "monthly",
18
+ "rolling_window",
19
+ "summarize",
20
+ "weekly",
21
+ "yearly",
22
+ ]
@@ -0,0 +1,130 @@
1
+ """Pure calculations over daily observations. Missing days are never treated as zero."""
2
+
3
+ import calendar
4
+ from collections.abc import Callable, Iterator
5
+ from datetime import date, timedelta
6
+
7
+ from .storage import History
8
+
9
+ _ONE_DAY = timedelta(days=1)
10
+
11
+
12
+ def tally(history: History, start: date, end: date) -> dict:
13
+ """Total an inclusive date range. `downloads` is None when no day in it was recorded."""
14
+ days = (end - start).days + 1
15
+ values = [history[day] for day in _dates(start, end) if day in history]
16
+ return {
17
+ "start": start.isoformat(),
18
+ "end": end.isoformat(),
19
+ "days": days,
20
+ "downloads": sum(values) if values else None,
21
+ "recorded_days": len(values),
22
+ "missing_days": days - len(values),
23
+ "complete": len(values) == days,
24
+ }
25
+
26
+
27
+ def rolling_window(history: History, days: int) -> dict:
28
+ """Tally the `days` days ending at the latest recorded date, not at today's date.
29
+
30
+ `observations` has one entry per day in the window, so charts always get `days` points.
31
+ A day with no data has `downloads: None`. With no history at all the list is empty.
32
+ """
33
+ if days < 1:
34
+ raise ValueError(f"days must be positive, got {days}")
35
+ if not history:
36
+ return {
37
+ "start": None,
38
+ "end": None,
39
+ "days": days,
40
+ "downloads": None,
41
+ "recorded_days": 0,
42
+ "missing_days": days,
43
+ "complete": False,
44
+ "observations": [],
45
+ }
46
+ end = max(history)
47
+ start = end - _ONE_DAY * (days - 1)
48
+ return {
49
+ **tally(history, start, end),
50
+ "observations": [
51
+ {"date": day.isoformat(), "downloads": history.get(day)} for day in _dates(start, end)
52
+ ],
53
+ }
54
+
55
+
56
+ def weekly(history: History) -> list[dict]:
57
+ """ISO calendar weeks, Monday through Sunday."""
58
+ return [
59
+ {"week": "{}-W{:02d}".format(*start.isocalendar()[:2]), **tally(history, start, end)}
60
+ for start, end in _spans(history, _week_span)
61
+ ]
62
+
63
+
64
+ def monthly(history: History) -> list[dict]:
65
+ return [
66
+ {"month": f"{start:%Y-%m}", **tally(history, start, end)}
67
+ for start, end in _spans(history, _month_span)
68
+ ]
69
+
70
+
71
+ def yearly(history: History) -> list[dict]:
72
+ return [
73
+ {"year": start.year, **tally(history, start, end)}
74
+ for start, end in _spans(history, _year_span)
75
+ ]
76
+
77
+
78
+ def find_gaps(history: History) -> list[dict]:
79
+ """Runs of unrecorded days between the earliest and latest observations."""
80
+ days = sorted(history)
81
+ return [
82
+ {
83
+ "start": (before + _ONE_DAY).isoformat(),
84
+ "end": (after - _ONE_DAY).isoformat(),
85
+ "days": (after - before).days - 1,
86
+ }
87
+ for before, after in zip(days, days[1:])
88
+ if after - before > _ONE_DAY
89
+ ]
90
+
91
+
92
+ def summarize(history: History) -> dict:
93
+ """Overall coverage. Totals are "recorded": history may not reach back to a package's start."""
94
+ gaps = find_gaps(history)
95
+ return {
96
+ "earliest_date": min(history).isoformat() if history else None,
97
+ "latest_date": max(history).isoformat() if history else None,
98
+ "recorded_downloads": sum(history.values()),
99
+ "recorded_days": len(history),
100
+ "missing_days": sum(gap["days"] for gap in gaps),
101
+ "gaps": gaps,
102
+ }
103
+
104
+
105
+ def _dates(start: date, end: date) -> list[date]:
106
+ return [start + _ONE_DAY * offset for offset in range((end - start).days + 1)]
107
+
108
+
109
+ def _spans(history: History, span: Callable[[date], tuple[date, date]]) -> Iterator[tuple[date, date]]:
110
+ """Every period from the earliest to latest observation, including periods with no data."""
111
+ if not history:
112
+ return
113
+ day, last = min(history), max(history)
114
+ while day <= last:
115
+ start, end = span(day)
116
+ yield start, end
117
+ day = end + _ONE_DAY
118
+
119
+
120
+ def _week_span(day: date) -> tuple[date, date]:
121
+ monday = day - _ONE_DAY * day.weekday()
122
+ return monday, monday + _ONE_DAY * 6
123
+
124
+
125
+ def _month_span(day: date) -> tuple[date, date]:
126
+ return day.replace(day=1), day.replace(day=calendar.monthrange(day.year, day.month)[1])
127
+
128
+
129
+ def _year_span(day: date) -> tuple[date, date]:
130
+ return date(day.year, 1, 1), date(day.year, 12, 31)
pypi_tracker/cli.py ADDED
@@ -0,0 +1,117 @@
1
+ """The `pypi-tracker` command line. All real work happens in Tracker."""
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from importlib.metadata import version
7
+
8
+ from .config import resolve_path
9
+ from .errors import ConfigError, TrackerError
10
+ from .tracker import Tracker
11
+
12
+ EXAMPLE_CONFIG = """\
13
+ packages = ["example-package"]
14
+
15
+ [reports]
16
+ rolling_days = [7, 30, 90, 180]
17
+ calendar = ["weekly", "monthly", "yearly"]
18
+ historical = ["daily"]
19
+ summary = true
20
+
21
+ [storage]
22
+ directory = "data/"
23
+
24
+ [output]
25
+ directory = "public/"
26
+ """
27
+
28
+
29
+ def main(argv: Sequence[str] | None = None) -> int:
30
+ args = _parser().parse_args(argv)
31
+ try:
32
+ return args.command(args)
33
+ except (TrackerError, OSError) as error:
34
+ print(f"error: {error}", file=sys.stderr)
35
+ return 1
36
+
37
+
38
+ def _parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="pypi-tracker", description="Preserve PyPI download history and export static JSON."
41
+ )
42
+ # shortcut: needs installed package metadata, so the CLI won't start from a bare PYTHONPATH checkout.
43
+ parser.add_argument("--version", action="version", version=f"%(prog)s {version('pypi-tracker')}")
44
+ commands = parser.add_subparsers(required=True)
45
+
46
+ source = argparse.ArgumentParser(add_help=False)
47
+ source.add_argument("--config", help="TOML configuration file")
48
+ source.add_argument("--data-dir", help="directory of historical CSV files")
49
+ source.add_argument(
50
+ "--package", action="append", help="package to process, instead of the config's (repeatable)"
51
+ )
52
+ output = argparse.ArgumentParser(add_help=False)
53
+ output.add_argument("--output-dir", help="directory receiving JSON reports")
54
+
55
+ def add(name: str, handler, help: str, parents: list) -> argparse.ArgumentParser:
56
+ command = commands.add_parser(name, help=help, parents=parents)
57
+ command.set_defaults(command=handler)
58
+ return command
59
+
60
+ add("collect", _collect, "fetch observations and merge them into CSV storage", [source])
61
+ add("build", _build, "generate JSON reports from stored history", [source, output])
62
+ add("sync", _sync, "collect, then build", [source, output])
63
+ add("validate", _validate, "check the historical dataset", [source])
64
+
65
+ init = add("init", _init, "write an example configuration file", [])
66
+ init.add_argument("--output", default="pypi-tracker.toml", help="where to write it")
67
+ return parser
68
+
69
+
70
+ def _tracker(args: argparse.Namespace) -> Tracker:
71
+ output_dir = getattr(args, "output_dir", None)
72
+ if args.config:
73
+ return Tracker.from_config(
74
+ args.config, data_dir=args.data_dir, output_dir=output_dir, packages=args.package
75
+ )
76
+ paths = {"data_dir": args.data_dir, "output_dir": output_dir}
77
+ return Tracker(args.package or (), **{key: value for key, value in paths.items() if value})
78
+
79
+
80
+ def _collect(args: argparse.Namespace) -> int:
81
+ tracker = _tracker(args)
82
+ changed = tracker.collect()
83
+ print(f"collected {len(tracker.packages)} package(s), {len(changed)} changed")
84
+ return 0
85
+
86
+
87
+ def _build(args: argparse.Namespace) -> int:
88
+ written = _tracker(args).build()
89
+ print(f"built reports, {len(written)} file(s) changed")
90
+ return 0
91
+
92
+
93
+ def _sync(args: argparse.Namespace) -> int:
94
+ changed, written = _tracker(args).sync()
95
+ print(f"synced: {len(changed)} package(s) changed, {len(written)} report file(s) changed")
96
+ return 0
97
+
98
+
99
+ def _validate(args: argparse.Namespace) -> int:
100
+ results = _tracker(args).validate()
101
+ for package, result in results.items():
102
+ print(f"{package}: {result['recorded_days']} recorded day(s)")
103
+ for problem in result["problems"]:
104
+ print(f" problem: {problem}")
105
+ for gap in result["gaps"]:
106
+ print(f" coverage gap: {gap['start']} to {gap['end']} ({gap['days']} day(s))")
107
+ return 1 if any(result["problems"] for result in results.values()) else 0
108
+
109
+
110
+ def _init(args: argparse.Namespace) -> int:
111
+ path = resolve_path(args.output)
112
+ if path.exists():
113
+ raise ConfigError(f"{path} already exists")
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
116
+ print(f"wrote {path}")
117
+ return 0
pypi_tracker/client.py ADDED
@@ -0,0 +1,78 @@
1
+ """Read daily download counts from the PyPIStats `/overall` endpoint."""
2
+
3
+ import http.client
4
+ import json
5
+ import time
6
+ from datetime import UTC, date, datetime
7
+ from email.utils import parsedate_to_datetime
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ from .errors import PyPIStatsError
12
+
13
+ URL = "https://pypistats.org/api/packages/{package}/overall?mirrors=false"
14
+ CATEGORY = "without_mirrors"
15
+ TIMEOUT_SECONDS = 30
16
+ MAX_ATTEMPTS = 4
17
+ MAX_WAIT_SECONDS = 120
18
+
19
+
20
+ def fetch_overall(package: str) -> dict[date, int]:
21
+ """Return all daily observations PyPIStats has for a package."""
22
+ request = Request(URL.format(package=package), headers={"User-Agent": "pypi-tracker"})
23
+ problem = ""
24
+ for attempt in range(1, MAX_ATTEMPTS + 1):
25
+ wait = 2**attempt
26
+ try:
27
+ with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
28
+ body = response.read()
29
+ except HTTPError as error:
30
+ if error.code == 404:
31
+ raise PyPIStatsError("package not found on PyPIStats") from error
32
+ if error.code != 429 and error.code < 500:
33
+ raise PyPIStatsError(f"PyPIStats returned HTTP {error.code}") from error
34
+ problem = f"PyPIStats returned HTTP {error.code}"
35
+ wait = _retry_after(error.headers.get("Retry-After")) or wait
36
+ except (URLError, OSError, http.client.HTTPException) as error:
37
+ problem = f"network error: {error}"
38
+ else:
39
+ return parse_overall(body)
40
+
41
+ if wait > MAX_WAIT_SECONDS:
42
+ raise PyPIStatsError(f"{problem}; server asked to wait {wait:.0f} seconds")
43
+ if attempt < MAX_ATTEMPTS:
44
+ time.sleep(wait)
45
+ raise PyPIStatsError(f"{problem} (after {MAX_ATTEMPTS} attempts)")
46
+
47
+
48
+ def parse_overall(body: bytes) -> dict[date, int]:
49
+ """Turn a response body into {date: downloads}, rejecting anything suspicious."""
50
+ observations: dict[date, int] = {}
51
+ try:
52
+ for row in json.loads(body)["data"]:
53
+ if row["category"] != CATEGORY:
54
+ continue
55
+ day = date.fromisoformat(row["date"])
56
+ downloads = row["downloads"]
57
+ if type(downloads) is not int or downloads < 0:
58
+ raise ValueError(f"invalid download count {downloads!r} for {day}")
59
+ if day in observations:
60
+ raise ValueError(f"duplicate observation for {day}")
61
+ observations[day] = downloads
62
+ except (ValueError, KeyError, TypeError) as error:
63
+ raise PyPIStatsError(f"malformed PyPIStats response: {error!r}") from error
64
+ return observations
65
+
66
+
67
+ def _retry_after(header: str | None) -> float | None:
68
+ """Parse a Retry-After header, which is either seconds or an HTTP date."""
69
+ if not header:
70
+ return None
71
+ try:
72
+ return max(float(header), 0)
73
+ except ValueError:
74
+ pass
75
+ try:
76
+ return max((parsedate_to_datetime(header) - datetime.now(UTC)).total_seconds(), 0)
77
+ except (ValueError, TypeError):
78
+ return None
pypi_tracker/config.py ADDED
@@ -0,0 +1,124 @@
1
+ """Load and validate the TOML configuration file."""
2
+
3
+ import re
4
+ import tomllib
5
+ from collections.abc import Iterable, Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .errors import ConfigError
10
+
11
+ DEFAULT_DATA_DIR = "data"
12
+ DEFAULT_OUTPUT_DIR = "public"
13
+ CALENDAR_REPORTS = ("weekly", "monthly", "yearly")
14
+ HISTORICAL_REPORTS = ("daily",)
15
+
16
+ # Names become file paths, so this also blocks path traversal.
17
+ _PACKAGE_NAME = re.compile(r"[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ReportSettings:
22
+ """Which reports `build` generates. Every report is on by default."""
23
+
24
+ rolling_days: Sequence[int] = (7, 30, 90, 180)
25
+ calendar: Sequence[str] = CALENDAR_REPORTS
26
+ historical: Sequence[str] = HISTORICAL_REPORTS
27
+ summary: bool = True
28
+
29
+ def __post_init__(self):
30
+ _require_list("reports.rolling_days", self.rolling_days)
31
+ for days in self.rolling_days:
32
+ if type(days) is not int or days < 1:
33
+ raise ConfigError(f"reports.rolling_days must be positive integers, got {days!r}")
34
+ _require_choices("reports.calendar", self.calendar, CALENDAR_REPORTS)
35
+ _require_choices("reports.historical", self.historical, HISTORICAL_REPORTS)
36
+ if type(self.summary) is not bool:
37
+ raise ConfigError(f"reports.summary must be true or false, got {self.summary!r}")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Config:
42
+ packages: list[str]
43
+ reports: ReportSettings
44
+ data_dir: Path | None
45
+ output_dir: Path | None
46
+
47
+
48
+ def normalize_packages(names: Iterable[str]) -> list[str]:
49
+ """Validate names and apply PEP 503 normalization, so `Foo_Bar` and `foo-bar` share one CSV."""
50
+ packages = []
51
+ for name in names:
52
+ if not isinstance(name, str) or not _PACKAGE_NAME.fullmatch(name):
53
+ raise ConfigError(f"invalid package name: {name!r}")
54
+ packages.append(re.sub(r"[-_.]+", "-", name).lower())
55
+ return list(dict.fromkeys(packages))
56
+
57
+
58
+ def resolve_path(value: str | Path, base: Path | None = None) -> Path:
59
+ """Expand `~` and make a path absolute. Relative paths start at `base` (default: the cwd)."""
60
+ return ((base or Path.cwd()) / Path(value).expanduser()).resolve()
61
+
62
+
63
+ def load_config(path: str | Path) -> Config:
64
+ path = resolve_path(path)
65
+ try:
66
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
67
+ except OSError as error:
68
+ raise ConfigError(f"cannot read config file {path}: {error}") from error
69
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as error:
70
+ raise ConfigError(f"invalid TOML in {path}: {error}") from error
71
+ try:
72
+ return _parse(raw, base=path.parent)
73
+ except ConfigError as error:
74
+ raise ConfigError(f"{path}: {error}") from error
75
+
76
+
77
+ def _parse(raw: dict, base: Path) -> Config:
78
+ _reject_unknown_keys("top level", raw, {"packages", "reports", "storage", "output"})
79
+
80
+ packages = raw.get("packages") or []
81
+ _require_list("packages", packages)
82
+
83
+ reports = _section(raw, "reports", {"rolling_days", "calendar", "historical", "summary"})
84
+ return Config(
85
+ packages=normalize_packages(packages),
86
+ reports=ReportSettings(**reports),
87
+ data_dir=_directory(raw, "storage", base),
88
+ output_dir=_directory(raw, "output", base),
89
+ )
90
+
91
+
92
+ def _directory(raw: dict, key: str, base: Path) -> Path | None:
93
+ directory = _section(raw, key, {"directory"}).get("directory")
94
+ if directory is None:
95
+ return None
96
+ if not isinstance(directory, str):
97
+ raise ConfigError(f"{key}.directory must be a path, got {directory!r}")
98
+ return resolve_path(directory, base)
99
+
100
+
101
+ def _section(raw: dict, key: str, allowed: set[str]) -> dict:
102
+ section = raw.get(key) or {}
103
+ if not isinstance(section, dict):
104
+ raise ConfigError(f"'{key}' must be a mapping")
105
+ _reject_unknown_keys(key, section, allowed)
106
+ return section
107
+
108
+
109
+ def _reject_unknown_keys(where: str, mapping: dict, allowed: set[str]) -> None:
110
+ unknown = sorted(str(key) for key in set(mapping) - allowed)
111
+ if unknown:
112
+ raise ConfigError(f"unknown key(s) in {where}: {', '.join(unknown)}")
113
+
114
+
115
+ def _require_list(name: str, value: object) -> None:
116
+ if not isinstance(value, (list, tuple)):
117
+ raise ConfigError(f"{name} must be a list, got {value!r}")
118
+
119
+
120
+ def _require_choices(name: str, value: object, choices: tuple[str, ...]) -> None:
121
+ _require_list(name, value)
122
+ for item in value:
123
+ if item not in choices:
124
+ raise ConfigError(f"{name}: unknown report {item!r}; choose from {', '.join(choices)}")
pypi_tracker/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ """Exceptions raised by pypi-tracker."""
2
+
3
+
4
+ class TrackerError(Exception):
5
+ """Base class for every expected pypi-tracker failure."""
6
+
7
+
8
+ class ConfigError(TrackerError):
9
+ """The configuration or a package name is invalid."""
10
+
11
+
12
+ class PyPIStatsError(TrackerError):
13
+ """PyPIStats could not provide usable data for a package."""
14
+
15
+
16
+ class HistoryError(TrackerError):
17
+ """A historical CSV file is missing or invalid."""
18
+
19
+
20
+ class PartialFailure(TrackerError):
21
+ """One or more packages failed; the others were still processed."""
22
+
23
+ def __init__(self, action: str, failures: dict[str, str]):
24
+ self.failures = failures
25
+ details = "\n".join(f" {package}: {reason}" for package, reason in failures.items())
26
+ super().__init__(f"{action} failed for {len(failures)} package(s):\n{details}")
@@ -0,0 +1,87 @@
1
+ """Shape aggregates into report payloads and publish them as static JSON files."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from . import aggregation
7
+ from .config import ReportSettings
8
+ from .storage import History, write_atomic
9
+
10
+ _CALENDAR = {
11
+ "weekly": aggregation.weekly,
12
+ "monthly": aggregation.monthly,
13
+ "yearly": aggregation.yearly,
14
+ }
15
+
16
+
17
+ def rolling_report(package: str, history: History, days: int) -> dict:
18
+ window = aggregation.rolling_window(history, days)
19
+ return {
20
+ "package": package,
21
+ "report": f"last-{days}-days",
22
+ "as_of": _as_of(history),
23
+ "period": {"start": window["start"], "end": window["end"], "days": days},
24
+ "downloads": window["downloads"],
25
+ "recorded_days": window["recorded_days"],
26
+ "missing_days": window["missing_days"],
27
+ "complete": window["complete"],
28
+ "observations": window["observations"],
29
+ }
30
+
31
+
32
+ def calendar_report(package: str, history: History, name: str) -> dict:
33
+ """`name` is weekly, monthly or yearly."""
34
+ return {
35
+ "package": package,
36
+ "report": name,
37
+ "as_of": _as_of(history),
38
+ "periods": _CALENDAR[name](history),
39
+ }
40
+
41
+
42
+ def summary_report(package: str, history: History) -> dict:
43
+ return {
44
+ "package": package,
45
+ "report": "summary",
46
+ "as_of": _as_of(history),
47
+ **aggregation.summarize(history),
48
+ }
49
+
50
+
51
+ def daily_reports(package: str, history: History) -> dict[int, dict]:
52
+ """One payload per calendar year that has observations."""
53
+ years: dict[int, dict] = {}
54
+ for day, downloads in sorted(history.items()):
55
+ year = years.setdefault(
56
+ day.year, {"package": package, "year": day.year, "observations": []}
57
+ )
58
+ year["observations"].append({"date": day.isoformat(), "downloads": downloads})
59
+ return years
60
+
61
+
62
+ def write_reports(
63
+ package: str, history: History, output_dir: Path, settings: ReportSettings
64
+ ) -> list[Path]:
65
+ """Write a package's selected reports and return the files that actually changed."""
66
+ files: dict[str, str] = {}
67
+ if settings.summary:
68
+ files["summary.json"] = _readable(summary_report(package, history))
69
+ for days in settings.rolling_days:
70
+ files[f"last-{days}-days.json"] = _readable(rolling_report(package, history, days))
71
+ for name in settings.calendar:
72
+ files[f"{name}.json"] = _readable(calendar_report(package, history, name))
73
+ if "daily" in settings.historical:
74
+ for year, payload in daily_reports(package, history).items():
75
+ files[f"daily/{year}.json"] = json.dumps(payload, separators=(",", ":")) + "\n"
76
+
77
+ root = output_dir / "packages" / package
78
+ return [root / name for name, text in files.items() if write_atomic(root / name, text)]
79
+
80
+
81
+ def _as_of(history: History) -> str | None:
82
+ """The actual date of the newest data, so stale reports can be recognized as stale."""
83
+ return max(history).isoformat() if history else None
84
+
85
+
86
+ def _readable(payload: dict) -> str:
87
+ return json.dumps(payload, indent=2) + "\n"
@@ -0,0 +1,96 @@
1
+ """Historical CSV files (the source of truth) and atomic file writes."""
2
+
3
+ import csv
4
+ import os
5
+ import re
6
+ from datetime import date
7
+ from pathlib import Path
8
+
9
+ from .errors import HistoryError
10
+
11
+ History = dict[date, int]
12
+
13
+ _HEADER = ["date", "downloads"]
14
+
15
+
16
+ def write_atomic(path: Path, text: str) -> bool:
17
+ """Replace a file's contents in one step. Returns False if it already had this content."""
18
+ data = text.encode("utf-8")
19
+ if path.exists() and path.read_bytes() == data:
20
+ return False
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
23
+ try:
24
+ with temporary.open("wb") as file:
25
+ file.write(data)
26
+ file.flush()
27
+ os.fsync(file.fileno())
28
+ os.replace(temporary, path)
29
+ finally:
30
+ temporary.unlink(missing_ok=True)
31
+ return True
32
+
33
+
34
+ def scan_csv(path: Path) -> tuple[History, list[str]]:
35
+ """Read a history file, returning its valid observations and every problem found."""
36
+ if not path.exists():
37
+ return {}, ["file not found"]
38
+
39
+ history: History = {}
40
+ problems: list[str] = []
41
+ previous: date | None = None
42
+ with path.open(newline="", encoding="utf-8", errors="replace") as file:
43
+ reader = csv.reader(file)
44
+ try:
45
+ header = next(reader, None)
46
+ if header != _HEADER:
47
+ problems.append(f"line 1: expected header date,downloads, got {header}")
48
+ for row in reader:
49
+ where = f"line {reader.line_num}"
50
+ if len(row) != 2:
51
+ problems.append(f"{where}: expected 2 columns, got {row}")
52
+ continue
53
+ day = _parse_date(row[0])
54
+ if day is None:
55
+ problems.append(f"{where}: malformed date {row[0]!r}")
56
+ continue
57
+ if not re.fullmatch(r"-?[0-9]+", row[1]):
58
+ problems.append(f"{where}: malformed download count {row[1]!r}")
59
+ continue
60
+ downloads = int(row[1])
61
+ if downloads < 0:
62
+ problems.append(f"{where}: negative download count {downloads}")
63
+ continue
64
+ if day in history:
65
+ problems.append(f"{where}: duplicate observation for {day}")
66
+ continue
67
+ if previous and day < previous:
68
+ problems.append(f"{where}: {day} is out of order (after {previous})")
69
+ history[day] = downloads
70
+ previous = day
71
+ except csv.Error as error:
72
+ problems.append(f"line {reader.line_num}: {error}")
73
+ return history, problems
74
+
75
+
76
+ def read_history(path: Path) -> History:
77
+ """Read a history file, refusing to return partial data from an invalid one."""
78
+ history, problems = scan_csv(path)
79
+ if problems:
80
+ raise HistoryError(f"{path}: " + "; ".join(problems))
81
+ return history
82
+
83
+
84
+ def write_history(path: Path, history: History) -> bool:
85
+ """Write observations in date order. Returns False if the file was already up to date."""
86
+ rows = "".join(f"{day.isoformat()},{downloads}\n" for day, downloads in sorted(history.items()))
87
+ return write_atomic(path, "date,downloads\n" + rows)
88
+
89
+
90
+ def _parse_date(text: str) -> date | None:
91
+ try:
92
+ day = date.fromisoformat(text)
93
+ except ValueError:
94
+ return None
95
+ # fromisoformat also accepts forms such as 20260101; stored dates must be YYYY-MM-DD.
96
+ return day if day.isoformat() == text else None
@@ -0,0 +1,133 @@
1
+ """The public Python API: collect, build, sync and validate."""
2
+
3
+ from collections.abc import Iterable
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from typing import Self
7
+
8
+ from . import aggregation, reports
9
+ from .client import fetch_overall
10
+ from .config import (
11
+ DEFAULT_DATA_DIR,
12
+ DEFAULT_OUTPUT_DIR,
13
+ ReportSettings,
14
+ load_config,
15
+ normalize_packages,
16
+ resolve_path,
17
+ )
18
+ from .errors import ConfigError, PartialFailure, TrackerError
19
+ from .storage import History, read_history, scan_csv, write_history
20
+
21
+
22
+ class Tracker:
23
+ def __init__(
24
+ self,
25
+ packages: Iterable[str] = (),
26
+ data_dir: str | Path = DEFAULT_DATA_DIR,
27
+ output_dir: str | Path = DEFAULT_OUTPUT_DIR,
28
+ reports: ReportSettings = ReportSettings(),
29
+ ):
30
+ self.packages = normalize_packages(packages)
31
+ self.data_dir = resolve_path(data_dir)
32
+ self.output_dir = resolve_path(output_dir)
33
+ self.reports = reports
34
+
35
+ @classmethod
36
+ def from_config(
37
+ cls,
38
+ path: str | Path,
39
+ data_dir: str | Path | None = None,
40
+ output_dir: str | Path | None = None,
41
+ packages: Iterable[str] | None = None,
42
+ ) -> Self:
43
+ """Load a config file. Explicit arguments win over its values, which win over defaults."""
44
+ config = load_config(path)
45
+ return cls(
46
+ packages=packages or config.packages,
47
+ data_dir=data_dir or config.data_dir or DEFAULT_DATA_DIR,
48
+ output_dir=output_dir or config.output_dir or DEFAULT_OUTPUT_DIR,
49
+ reports=config.reports,
50
+ )
51
+
52
+ def collect(self) -> list[str]:
53
+ """Merge PyPIStats data into each package's CSV and return the packages that changed.
54
+
55
+ Raises PartialFailure after trying every package if any of them failed.
56
+ """
57
+ if not self.packages:
58
+ raise ConfigError("no packages to collect: use --package or list them in a config file")
59
+ changed, failures = [], {}
60
+ for package in self.packages:
61
+ try:
62
+ if self._collect_one(package):
63
+ changed.append(package)
64
+ except (TrackerError, OSError) as error:
65
+ failures[package] = str(error)
66
+ if failures:
67
+ raise PartialFailure("collection", failures)
68
+ return changed
69
+
70
+ def build(self, packages: Iterable[str] | None = None) -> list[Path]:
71
+ """Generate JSON reports from local CSVs and return the files that changed.
72
+
73
+ Never touches the network. Raises PartialFailure after trying every package.
74
+ """
75
+ written, failures = [], {}
76
+ for package in self._selected() if packages is None else packages:
77
+ try:
78
+ written += reports.write_reports(
79
+ package, self.history(package), self.output_dir, self.reports
80
+ )
81
+ except (TrackerError, OSError) as error:
82
+ failures[package] = str(error)
83
+ if failures:
84
+ raise PartialFailure("report generation", failures)
85
+ return written
86
+
87
+ def sync(self) -> tuple[list[str], list[Path]]:
88
+ """Collect, then build. Returns (changed packages, changed report files).
89
+
90
+ If collection fails for a package, the others are still published, its existing reports
91
+ are left alone (their `as_of` shows the data's age), and PartialFailure is raised.
92
+ """
93
+ try:
94
+ changed = self.collect()
95
+ except PartialFailure as failure:
96
+ self.build(package for package in self.packages if package not in failure.failures)
97
+ raise
98
+ return changed, self.build()
99
+
100
+ def history(self, package: str) -> History:
101
+ """Stored observations for a package as {date: downloads}."""
102
+ return read_history(self._csv_path(package))
103
+
104
+ def validate(self) -> dict[str, dict]:
105
+ """Check each CSV. `problems` are invalid data; `gaps` are only missing coverage."""
106
+ results = {}
107
+ for package in self._selected():
108
+ history, problems = scan_csv(self._csv_path(package))
109
+ results[package] = {
110
+ "problems": problems,
111
+ "gaps": aggregation.find_gaps(history),
112
+ "recorded_days": len(history),
113
+ }
114
+ return results
115
+
116
+ def _collect_one(self, package: str) -> bool:
117
+ path = self._csv_path(package)
118
+ # Read first: a damaged CSV must stop us before anything can overwrite it.
119
+ existing = read_history(path) if path.exists() else {}
120
+ today = datetime.now(UTC).date()
121
+ fetched = {day: count for day, count in fetch_overall(package).items() if day < today}
122
+ # Upstream wins on overlap (corrections); dates it no longer returns are kept (retention).
123
+ return write_history(path, existing | fetched)
124
+
125
+ def _csv_path(self, package: str) -> Path:
126
+ return self.data_dir / f"{package}.csv"
127
+
128
+ def _selected(self) -> list[str]:
129
+ """Configured packages, or else every package with history in the data directory."""
130
+ packages = self.packages or sorted(path.stem for path in self.data_dir.glob("*.csv"))
131
+ if not packages:
132
+ raise ConfigError(f"no packages configured and no CSV files found in {self.data_dir}")
133
+ return packages
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: pypi-tracker
3
+ Version: 0.1.0
4
+ Summary: Permanently preserve PyPI download history and export it as static JSON reports.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == "dev"
11
+ Dynamic: license-file
12
+
13
+ # pypi-tracker
14
+
15
+ pypi-tracker is a lightweight tool for collecting and preserving PyPI package download statistics. It maintains historical data beyond [PyPIStats](https://pypistats.org)' retention window. It generates static, chart-ready JSON reports that can be used to visualize download trends, track package adoption, and power dashboards or other applications without a backend.
16
+
17
+
18
+ ## Try it
19
+
20
+ ```bash
21
+ pip install pypi-tracker
22
+
23
+
24
+ pypi-tracker sync --package dj-control-room
25
+ ```
26
+
27
+ You now have the full history in `data/dj-control-room.csv` and reports in
28
+ `public/packages/dj-control-room/`, such as `last-30-days.json`:
29
+
30
+ ```json
31
+ {
32
+ "package": "dj-control-room",
33
+ "report": "last-30-days",
34
+ "as_of": "2026-11-01",
35
+ "downloads": 38071,
36
+ "complete": true,
37
+ "observations": [
38
+ {"date": "2026-10-03", "downloads": 510},
39
+ {"date": "2026-10-04", "downloads": 757}
40
+ ]
41
+ }
42
+ ```
43
+
44
+ The `observations` list has one entry per day in the window (shortened here). A day with no data is `null`,
45
+ never `0`, and `complete` tells you whether any day is missing.
46
+
47
+ ## Commands
48
+
49
+ | Command | What it does |
50
+ |---|---|
51
+ | `init` | Creates a starter config file. |
52
+ | `sync` | Runs `collect`, then `build`. This is the one to schedule. |
53
+ | `collect` | Fetches new data from PyPIStats and merges it into the CSVs. |
54
+ | `build` | Writes the JSON report files to publish. |
55
+ | `validate` | Checks the CSVs for bad data and missing dates. |
56
+
57
+ ## Tracking several packages
58
+
59
+ You will typically want to keep a config file where you specify the packages you want to track.
60
+
61
+ ```bash
62
+ pypi-tracker init # generates a new pypi-tracker.toml file in the current directory
63
+ ```
64
+
65
+ A complete config file will have the sections seen below. Note that the `pypi-tracker` also
66
+ allows for cli args that can supersede these config values; though typically it is much
67
+ easier to simply rely on a config file.
68
+ ```toml
69
+ # pypi-tracker.toml
70
+ packages = [
71
+ "dj-control-room",
72
+ "dj-control-room-base",
73
+ "dj-redis-panel",
74
+ "dj-cache-panel",
75
+ "dj-celery-panel",
76
+ "dj-urls-panel",
77
+ "dj-signals-panel",
78
+ ]
79
+
80
+ [reports]
81
+ rolling_days = [7, 30, 90, 180]
82
+ calendar = ["weekly", "monthly", "yearly"]
83
+ historical = ["daily"]
84
+ summary = true
85
+
86
+ [storage]
87
+ directory = "data/"
88
+
89
+ [output]
90
+ directory = "public/"
91
+ ```
92
+
93
+ You can update both reports and historical data by running a single `sync` command.
94
+ ```bash
95
+ pypi-tracker sync --config pypi-tracker.toml
96
+ ```
97
+
98
+ ## Keep your CSVs
99
+
100
+ Run `sync` on a schedule and commit `data/`, because **the CSV files are your history**. If you lose them, or
101
+ stop collecting for longer than PyPIStats keeps data, those days are gone. History also can't reach back
102
+ further than PyPIStats did on your first run, so totals are "recorded" downloads, not lifetime downloads.
103
+
104
+ ## More
105
+
106
+ The [guide](docs/guide.md) covers configuration, every report format, the Python API, and GitHub Actions and
107
+ Cloudflare Pages setup.
@@ -0,0 +1,15 @@
1
+ pypi_tracker/__init__.py,sha256=Zf4emouK-WZRAqVCYUKgrM2CvByyWcM3yiBwkkCdpgE,582
2
+ pypi_tracker/aggregation.py,sha256=lnEXkrcjMx5fRdnO9Z_gUucuJ3Q80yrdbJ5Gatyaajc,4234
3
+ pypi_tracker/cli.py,sha256=O61WVXG2t35qojNadUpxdB4MjGhZ76H0j3wpBcGGL3s,4207
4
+ pypi_tracker/client.py,sha256=SEYw9iGbrr1pyIN7EZ7RNDl8OdSd7BYz7tjfzECmP9o,3045
5
+ pypi_tracker/config.py,sha256=I2OZTxKIy6r3wmv40oFJSxXhlbGgwXrGZgGC_S6vkxU,4554
6
+ pypi_tracker/errors.py,sha256=jExiEhD3sfYBA_RwYN2_bHkqxO0ClVeldaHRyGsSVrM,812
7
+ pypi_tracker/reports.py,sha256=1GDmnK7GoryGQNr0z-rxGlBAYPxm0RM4QS9ky4Ur3Q4,3043
8
+ pypi_tracker/storage.py,sha256=pIkKSsyK5AwXwSFE69hCo_AoAV9K7PswN71zSHfrDGc,3549
9
+ pypi_tracker/tracker.py,sha256=eHXHrE-OeBKOqA34xkDHQjvTPejz7vM00V-UqWTfIN8,5294
10
+ pypi_tracker-0.1.0.dist-info/licenses/LICENSE,sha256=qCZ1uHegA5eg57GpTtj5Gx4bzmQsdHnt8BcDOQErnX0,1071
11
+ pypi_tracker-0.1.0.dist-info/METADATA,sha256=d5nRzkMg_urhL1w4cwPbXXM3UHuxUZ1EnpxUrv7rPAM,3226
12
+ pypi_tracker-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ pypi_tracker-0.1.0.dist-info/entry_points.txt,sha256=LEG-ZRmSo8fSv6wNDxEuuD3lxMyzPLBdZEfLqME_99o,55
14
+ pypi_tracker-0.1.0.dist-info/top_level.txt,sha256=WbD0M5xUqG8IQpjJLL1PzYmD_TxATYaoNCJBpLeyXs0,13
15
+ pypi_tracker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pypi-tracker = pypi_tracker.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yasser Toruño
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pypi_tracker