pypi-tracker 0.1.0__tar.gz

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,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,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,95 @@
1
+ # pypi-tracker
2
+
3
+ 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.
4
+
5
+
6
+ ## Try it
7
+
8
+ ```bash
9
+ pip install pypi-tracker
10
+
11
+
12
+ pypi-tracker sync --package dj-control-room
13
+ ```
14
+
15
+ You now have the full history in `data/dj-control-room.csv` and reports in
16
+ `public/packages/dj-control-room/`, such as `last-30-days.json`:
17
+
18
+ ```json
19
+ {
20
+ "package": "dj-control-room",
21
+ "report": "last-30-days",
22
+ "as_of": "2026-11-01",
23
+ "downloads": 38071,
24
+ "complete": true,
25
+ "observations": [
26
+ {"date": "2026-10-03", "downloads": 510},
27
+ {"date": "2026-10-04", "downloads": 757}
28
+ ]
29
+ }
30
+ ```
31
+
32
+ The `observations` list has one entry per day in the window (shortened here). A day with no data is `null`,
33
+ never `0`, and `complete` tells you whether any day is missing.
34
+
35
+ ## Commands
36
+
37
+ | Command | What it does |
38
+ |---|---|
39
+ | `init` | Creates a starter config file. |
40
+ | `sync` | Runs `collect`, then `build`. This is the one to schedule. |
41
+ | `collect` | Fetches new data from PyPIStats and merges it into the CSVs. |
42
+ | `build` | Writes the JSON report files to publish. |
43
+ | `validate` | Checks the CSVs for bad data and missing dates. |
44
+
45
+ ## Tracking several packages
46
+
47
+ You will typically want to keep a config file where you specify the packages you want to track.
48
+
49
+ ```bash
50
+ pypi-tracker init # generates a new pypi-tracker.toml file in the current directory
51
+ ```
52
+
53
+ A complete config file will have the sections seen below. Note that the `pypi-tracker` also
54
+ allows for cli args that can supersede these config values; though typically it is much
55
+ easier to simply rely on a config file.
56
+ ```toml
57
+ # pypi-tracker.toml
58
+ packages = [
59
+ "dj-control-room",
60
+ "dj-control-room-base",
61
+ "dj-redis-panel",
62
+ "dj-cache-panel",
63
+ "dj-celery-panel",
64
+ "dj-urls-panel",
65
+ "dj-signals-panel",
66
+ ]
67
+
68
+ [reports]
69
+ rolling_days = [7, 30, 90, 180]
70
+ calendar = ["weekly", "monthly", "yearly"]
71
+ historical = ["daily"]
72
+ summary = true
73
+
74
+ [storage]
75
+ directory = "data/"
76
+
77
+ [output]
78
+ directory = "public/"
79
+ ```
80
+
81
+ You can update both reports and historical data by running a single `sync` command.
82
+ ```bash
83
+ pypi-tracker sync --config pypi-tracker.toml
84
+ ```
85
+
86
+ ## Keep your CSVs
87
+
88
+ Run `sync` on a schedule and commit `data/`, because **the CSV files are your history**. If you lose them, or
89
+ stop collecting for longer than PyPIStats keeps data, those days are gone. History also can't reach back
90
+ further than PyPIStats did on your first run, so totals are "recorded" downloads, not lifetime downloads.
91
+
92
+ ## More
93
+
94
+ The [guide](docs/guide.md) covers configuration, every report format, the Python API, and GitHub Actions and
95
+ Cloudflare Pages setup.
@@ -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)
@@ -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
@@ -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