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.
- pypi_tracker/__init__.py +22 -0
- pypi_tracker/aggregation.py +130 -0
- pypi_tracker/cli.py +117 -0
- pypi_tracker/client.py +78 -0
- pypi_tracker/config.py +124 -0
- pypi_tracker/errors.py +26 -0
- pypi_tracker/reports.py +87 -0
- pypi_tracker/storage.py +96 -0
- pypi_tracker/tracker.py +133 -0
- pypi_tracker-0.1.0.dist-info/METADATA +107 -0
- pypi_tracker-0.1.0.dist-info/RECORD +15 -0
- pypi_tracker-0.1.0.dist-info/WHEEL +5 -0
- pypi_tracker-0.1.0.dist-info/entry_points.txt +2 -0
- pypi_tracker-0.1.0.dist-info/licenses/LICENSE +21 -0
- pypi_tracker-0.1.0.dist-info/top_level.txt +1 -0
pypi_tracker/__init__.py
ADDED
|
@@ -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}")
|
pypi_tracker/reports.py
ADDED
|
@@ -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"
|
pypi_tracker/storage.py
ADDED
|
@@ -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
|
pypi_tracker/tracker.py
ADDED
|
@@ -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,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
|