pkgreach 0.0.2__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.
- pkgreach/__init__.py +53 -0
- pkgreach/__main__.py +36 -0
- pkgreach/analyze.py +242 -0
- pkgreach/collect.py +301 -0
- pkgreach/config.py +120 -0
- pkgreach/http.py +129 -0
- pkgreach/platforms/__init__.py +103 -0
- pkgreach/platforms/crates.py +67 -0
- pkgreach/platforms/ecosystems.py +36 -0
- pkgreach/platforms/github.py +84 -0
- pkgreach/platforms/npm.py +90 -0
- pkgreach/platforms/pypi.py +74 -0
- pkgreach/report.py +290 -0
- pkgreach/scaffold.py +137 -0
- pkgreach/tables.py +120 -0
- pkgreach/tools.py +89 -0
- pkgreach-0.0.2.dist-info/METADATA +127 -0
- pkgreach-0.0.2.dist-info/RECORD +21 -0
- pkgreach-0.0.2.dist-info/WHEEL +4 -0
- pkgreach-0.0.2.dist-info/entry_points.txt +2 -0
- pkgreach-0.0.2.dist-info/licenses/LICENSE +21 -0
pkgreach/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Track how your packages are really used, across PyPI, npm, crates.io and GitHub.
|
|
2
|
+
|
|
3
|
+
Registries keep a few months of daily download counts at most, and none of
|
|
4
|
+
them separates people from robots. ``pkgreach`` collects the counts every week
|
|
5
|
+
into a git-friendly history, adds the signals a robot does not create
|
|
6
|
+
(dependent packages and repositories, stars, forks), and builds a static
|
|
7
|
+
report that puts each package against a noise floor.
|
|
8
|
+
|
|
9
|
+
>>> from pkgreach import collect, load_settings, build_site, Files # doctest: +SKIP
|
|
10
|
+
>>> collect(load_settings("pkgreach.toml"), store=Files("data")) # doctest: +SKIP
|
|
11
|
+
>>> build_site(Files("data"), Files("site")) # doctest: +SKIP
|
|
12
|
+
|
|
13
|
+
Seams (each one keyword argument, with a working default):
|
|
14
|
+
|
|
15
|
+
- ``collect(platforms=)``: which publishing platforms (:data:`PLATFORMS`).
|
|
16
|
+
- :class:`Selection` per platform, plus ``collect(select=)``: which packages.
|
|
17
|
+
- ``collect(dependents=)``: where dependents come from (ecosyste.ms).
|
|
18
|
+
- ``build_site(analyze=, render=)``: what is computed and how it is shown.
|
|
19
|
+
- ``store`` / ``out``: where the history and the site live (any
|
|
20
|
+
``MutableMapping[str, str]``: a folder by default).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from ghtraffic.tables import Files
|
|
24
|
+
|
|
25
|
+
from pkgreach.analyze import summarize
|
|
26
|
+
from pkgreach.collect import RunReport, collect, select_packages, write_manifest
|
|
27
|
+
from pkgreach.config import Selection, Settings, load_settings, settings_from_mapping
|
|
28
|
+
from pkgreach.http import HttpError, make_http
|
|
29
|
+
from pkgreach.platforms import PLATFORMS, Platform
|
|
30
|
+
from pkgreach.report import build_site, render_html
|
|
31
|
+
from pkgreach.scaffold import init_repo
|
|
32
|
+
from pkgreach.tables import TABLES
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Files",
|
|
36
|
+
"HttpError",
|
|
37
|
+
"PLATFORMS",
|
|
38
|
+
"Platform",
|
|
39
|
+
"RunReport",
|
|
40
|
+
"Selection",
|
|
41
|
+
"Settings",
|
|
42
|
+
"TABLES",
|
|
43
|
+
"build_site",
|
|
44
|
+
"collect",
|
|
45
|
+
"init_repo",
|
|
46
|
+
"load_settings",
|
|
47
|
+
"make_http",
|
|
48
|
+
"render_html",
|
|
49
|
+
"select_packages",
|
|
50
|
+
"settings_from_mapping",
|
|
51
|
+
"summarize",
|
|
52
|
+
"write_manifest",
|
|
53
|
+
]
|
pkgreach/__main__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""Command line: ``pkgreach collect | report | init``, one command per function
|
|
3
|
+
of :data:`pkgreach.tools.COMMANDS` (dispatched by ``cw``).
|
|
4
|
+
|
|
5
|
+
``pkgreach collect --config pkgreach.toml --data-dir data`` merges this week's
|
|
6
|
+
numbers into the history; ``pkgreach report --data-dir data --out site``
|
|
7
|
+
writes the static report; ``pkgreach init . --pypi you --github you`` sets up
|
|
8
|
+
a repository. Without ``--data-dir``, data goes to ``~/.local/share/pkgreach/data``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import cw
|
|
16
|
+
|
|
17
|
+
from pkgreach.config import SettingsError
|
|
18
|
+
from pkgreach.tools import COMMANDS, CollectionFailed
|
|
19
|
+
|
|
20
|
+
#: Keep injection-only parameters off the command line.
|
|
21
|
+
_CLI_CONFIG = {"collect": {"environ": cw.HIDE}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv=None) -> int:
|
|
25
|
+
"""Entry point of the ``pkgreach`` command; returns the exit code."""
|
|
26
|
+
try:
|
|
27
|
+
return cw.dispatch(
|
|
28
|
+
COMMANDS, argv=argv, config=_CLI_CONFIG, convention=cw.MODERN
|
|
29
|
+
)
|
|
30
|
+
except (CollectionFailed, SettingsError) as e:
|
|
31
|
+
print(f"pkgreach: {e}", file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
sys.exit(main())
|
pkgreach/analyze.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""From the history to a summary: windowed totals, growth, and a noise floor.
|
|
2
|
+
|
|
3
|
+
:func:`summarize` turns the tables into one JSON-ready dict, which the report
|
|
4
|
+
renders and anyone can consume (``summary.json``). It is the default of the
|
|
5
|
+
``analyze=`` seam of :func:`pkgreach.report.build_site`.
|
|
6
|
+
|
|
7
|
+
Download counts are mostly not people. Mirrors, CI pipelines, scanners and
|
|
8
|
+
bots download every release of every package, so a package nobody uses still
|
|
9
|
+
gets a steady trickle. The summary therefore puts each package against a
|
|
10
|
+
**noise floor**: the 25th percentile of 30-day downloads among the packages
|
|
11
|
+
you follow on that platform that were downloaded at all (an estimate of what
|
|
12
|
+
an unused package of yours gets). ``x_floor`` is a package's 30-day downloads over that floor; dependents
|
|
13
|
+
and stars, which a bot does not create, are reported next to it.
|
|
14
|
+
|
|
15
|
+
On PyPI the ``without_mirrors`` series (from pypistats.org) is used when it
|
|
16
|
+
exists, since mirror traffic is pure noise.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from collections.abc import Mapping, MutableMapping
|
|
24
|
+
from datetime import date, timedelta
|
|
25
|
+
from statistics import quantiles
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from pkgreach.platforms import PLATFORMS, Platform
|
|
29
|
+
from pkgreach.tables import LAST_RUN_KEY, TABLES, read_table
|
|
30
|
+
|
|
31
|
+
#: Windows (days) the summary totals over.
|
|
32
|
+
WINDOWS = (7, 30, 90, 365)
|
|
33
|
+
DFLT_FLOOR_QUANTILE = 0.25
|
|
34
|
+
#: Where the numbers come from, and on what terms (ecosyste.ms is CC BY-SA 4.0).
|
|
35
|
+
ATTRIBUTION = {
|
|
36
|
+
"downloads": "pypistats.org, api.npmjs.org, crates.io",
|
|
37
|
+
"dependents": "ecosyste.ms (https://ecosyste.ms), CC BY-SA 4.0 "
|
|
38
|
+
"(https://creativecommons.org/licenses/by-sa/4.0/), aggregated",
|
|
39
|
+
"github": "GitHub REST API, public repositories only",
|
|
40
|
+
}
|
|
41
|
+
SPARK_WEEKS = 26
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _daily(
|
|
45
|
+
store: MutableMapping, prefs_of: Mapping[str, tuple[str, ...]]
|
|
46
|
+
) -> dict[tuple[str, str], dict[str, int]]:
|
|
47
|
+
"""``{(platform, package): {date: count}}`` in one series per platform.
|
|
48
|
+
|
|
49
|
+
The series is the platform's most preferred one present for any package,
|
|
50
|
+
then used for all of them: zero days are not stored, so a package that a
|
|
51
|
+
preferred series counts as zero (all its PyPI downloads are mirrors) must
|
|
52
|
+
read as zero, not fall back to a noisier series.
|
|
53
|
+
"""
|
|
54
|
+
by_series: dict[tuple[str, str, str], dict[str, int]] = defaultdict(dict)
|
|
55
|
+
for r in read_table(store, TABLES["downloads"]):
|
|
56
|
+
key = (r["platform"], r["package"], r["series"])
|
|
57
|
+
by_series[key][str(r["date"])] = int(r["count"])
|
|
58
|
+
present: dict[str, set[str]] = defaultdict(set)
|
|
59
|
+
for platform, _, series in by_series:
|
|
60
|
+
present[platform].add(series)
|
|
61
|
+
chosen = {}
|
|
62
|
+
for platform, series_set in present.items():
|
|
63
|
+
prefs = prefs_of.get(platform, ("all",))
|
|
64
|
+
chosen[platform] = next(
|
|
65
|
+
(x for x in prefs if x in series_set), sorted(series_set)[0]
|
|
66
|
+
)
|
|
67
|
+
return {
|
|
68
|
+
(platform, package): days
|
|
69
|
+
for (platform, package, series), days in by_series.items()
|
|
70
|
+
if series == chosen[platform]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _window(days: Mapping[str, int], end: date, n: int) -> int:
|
|
75
|
+
start = (end - timedelta(days=n - 1)).isoformat()
|
|
76
|
+
stop = end.isoformat()
|
|
77
|
+
return sum(c for d, c in days.items() if start <= d <= stop)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _weekly(days: Mapping[str, int], end: date, weeks: int) -> list[int]:
|
|
81
|
+
return [
|
|
82
|
+
_window(days, end - timedelta(days=7 * (weeks - 1 - i)), 7)
|
|
83
|
+
for i in range(weeks)
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _metrics(store: MutableMapping, today: date, delta_days: int = 30):
|
|
88
|
+
"""``{(platform, package): {metric: {"value", "change_30d"}}}``."""
|
|
89
|
+
hist: dict[tuple, list[tuple[str, int]]] = defaultdict(list)
|
|
90
|
+
for r in read_table(store, TABLES["metrics"]):
|
|
91
|
+
if r["value"] != "":
|
|
92
|
+
hist[(r["platform"], r["package"], r["metric"])].append(
|
|
93
|
+
(str(r["date"]), int(r["value"]))
|
|
94
|
+
)
|
|
95
|
+
cutoff = (today - timedelta(days=delta_days)).isoformat()
|
|
96
|
+
out: dict[tuple[str, str], dict[str, dict]] = defaultdict(dict)
|
|
97
|
+
for (platform, package, metric), pts in hist.items():
|
|
98
|
+
pts.sort()
|
|
99
|
+
value = pts[-1][1]
|
|
100
|
+
before = [v for d, v in pts if d <= cutoff]
|
|
101
|
+
out[(platform, package)][metric] = {
|
|
102
|
+
"value": value,
|
|
103
|
+
"change_30d": value - before[-1] if before else None,
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _floor(values: list[int], q: float) -> int:
|
|
109
|
+
values = sorted(values)
|
|
110
|
+
if not values:
|
|
111
|
+
return 0
|
|
112
|
+
if len(values) < 4:
|
|
113
|
+
return values[0]
|
|
114
|
+
idx = min(99, max(1, round(q * 100))) - 1
|
|
115
|
+
return int(quantiles(values, n=100, method="inclusive")[idx])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _current(packages: Mapping[tuple[str, str], Mapping]) -> set[tuple[str, str]]:
|
|
119
|
+
"""The packages the latest run of their platform still followed.
|
|
120
|
+
|
|
121
|
+
A package dropped from the selection, deleted, or (for a repo) made private
|
|
122
|
+
leaves the report, though its past rows stay in the history.
|
|
123
|
+
"""
|
|
124
|
+
last_run: dict[str, str] = {}
|
|
125
|
+
for (platform, _), r in packages.items():
|
|
126
|
+
last_run[platform] = max(last_run.get(platform, ""), r.get("last_seen", ""))
|
|
127
|
+
return {k for k, r in packages.items() if r.get("last_seen", "") == last_run[k[0]]}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _ends(packages, daily) -> dict[str, date]:
|
|
131
|
+
"""Per platform, the last complete day: its last day with downloads, but
|
|
132
|
+
never the day of its last run (crates.io serves the partial current day,
|
|
133
|
+
pypistats and npm stop a day or two earlier)."""
|
|
134
|
+
last_run: dict[str, str] = {}
|
|
135
|
+
for (platform, _), r in packages.items():
|
|
136
|
+
last_run[platform] = max(last_run.get(platform, ""), r.get("last_seen", ""))
|
|
137
|
+
last_day: dict[str, str] = {}
|
|
138
|
+
for (platform, _), days in daily.items():
|
|
139
|
+
if days:
|
|
140
|
+
last_day[platform] = max(last_day.get(platform, ""), max(days))
|
|
141
|
+
ends = {}
|
|
142
|
+
for platform, d in last_day.items():
|
|
143
|
+
end = date.fromisoformat(d)
|
|
144
|
+
if last_run.get(platform):
|
|
145
|
+
end = min(end, date.fromisoformat(last_run[platform]) - timedelta(days=1))
|
|
146
|
+
ends[platform] = end
|
|
147
|
+
return ends
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run_errors(store: MutableMapping) -> int | None:
|
|
151
|
+
try:
|
|
152
|
+
return len(json.loads(store[LAST_RUN_KEY]).get("errors", {}))
|
|
153
|
+
except (KeyError, ValueError):
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def summarize(
|
|
158
|
+
store: MutableMapping,
|
|
159
|
+
*,
|
|
160
|
+
platforms: Mapping[str, Platform] = PLATFORMS,
|
|
161
|
+
today: date | None = None,
|
|
162
|
+
floor_quantile: float = DFLT_FLOOR_QUANTILE,
|
|
163
|
+
) -> dict[str, Any]:
|
|
164
|
+
"""A JSON-ready summary of the history in ``store``.
|
|
165
|
+
|
|
166
|
+
``platforms`` supplies how each platform is presented (title, kind,
|
|
167
|
+
headline metrics, preferred download series); a platform in the history
|
|
168
|
+
but not in ``platforms`` is shown as a daily-downloads platform.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def spec(name: str) -> Platform:
|
|
172
|
+
return platforms.get(name) or Platform(name, list, dict, title=name)
|
|
173
|
+
|
|
174
|
+
packages = {
|
|
175
|
+
(r["platform"], r["package"]): r for r in read_table(store, TABLES["packages"])
|
|
176
|
+
}
|
|
177
|
+
known = {name for name, _ in packages}
|
|
178
|
+
daily = _daily(store, {n: spec(n).preferred_series for n in known})
|
|
179
|
+
ends = _ends(packages, daily)
|
|
180
|
+
as_of = today or max(ends.values(), default=date.today())
|
|
181
|
+
metrics = _metrics(store, as_of)
|
|
182
|
+
rows = []
|
|
183
|
+
for key in sorted(_current(packages)):
|
|
184
|
+
platform, package = key
|
|
185
|
+
end = today or ends.get(platform, as_of)
|
|
186
|
+
days = daily.get(key, {})
|
|
187
|
+
info = packages.get(key, {})
|
|
188
|
+
row = {
|
|
189
|
+
"platform": platform,
|
|
190
|
+
"package": package,
|
|
191
|
+
"url": info.get("url", ""),
|
|
192
|
+
"repository": info.get("repository", ""),
|
|
193
|
+
"description": info.get("description", ""),
|
|
194
|
+
"latest_version": info.get("latest_version", ""),
|
|
195
|
+
"history_from": info.get("history_from", ""),
|
|
196
|
+
"last_seen": info.get("last_seen", ""),
|
|
197
|
+
"downloads": {f"{n}d": _window(days, end, n) for n in WINDOWS},
|
|
198
|
+
"downloads_prev_30d": _window(days, end - timedelta(days=30), 30),
|
|
199
|
+
"downloads_collected": sum(days.values()),
|
|
200
|
+
"has_downloads": bool(days or info.get("history_from")),
|
|
201
|
+
"weekly": _weekly(days, end, SPARK_WEEKS) if days else [],
|
|
202
|
+
"metrics": metrics.get(key, {}),
|
|
203
|
+
}
|
|
204
|
+
prev = row["downloads_prev_30d"]
|
|
205
|
+
row["growth_30d"] = (
|
|
206
|
+
round(row["downloads"]["30d"] / prev - 1, 3) if prev else None
|
|
207
|
+
)
|
|
208
|
+
rows.append(row)
|
|
209
|
+
|
|
210
|
+
by_platform: dict[str, dict] = {}
|
|
211
|
+
for platform in sorted({r["platform"] for r in rows}):
|
|
212
|
+
p = spec(platform)
|
|
213
|
+
mine = [r for r in rows if r["platform"] == platform]
|
|
214
|
+
# packages with no download at all say nothing about the noise level
|
|
215
|
+
with_dl = [r["downloads"]["30d"] for r in mine if r["downloads"]["30d"]]
|
|
216
|
+
floor = _floor(with_dl, floor_quantile)
|
|
217
|
+
for r in mine:
|
|
218
|
+
r["x_floor"] = (
|
|
219
|
+
round(r["downloads"]["30d"] / floor, 1)
|
|
220
|
+
if floor and r["has_downloads"]
|
|
221
|
+
else None
|
|
222
|
+
)
|
|
223
|
+
by_platform[platform] = {
|
|
224
|
+
"title": p.title or platform,
|
|
225
|
+
"kind": p.kind,
|
|
226
|
+
"headline": list(p.headline),
|
|
227
|
+
"packages": len(mine),
|
|
228
|
+
"downloads_30d": sum(r["downloads"]["30d"] for r in mine),
|
|
229
|
+
"downloads_prev_30d": sum(r["downloads_prev_30d"] for r in mine),
|
|
230
|
+
"noise_floor_30d": floor if with_dl else None,
|
|
231
|
+
"metric_totals": {
|
|
232
|
+
m: sum(r["metrics"].get(m, {}).get("value", 0) for r in mine)
|
|
233
|
+
for m in sorted({m for r in mine for m in r["metrics"]})
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
"as_of": as_of.isoformat(),
|
|
238
|
+
"last_run_errors": _run_errors(store),
|
|
239
|
+
"attribution": ATTRIBUTION,
|
|
240
|
+
"platforms": by_platform,
|
|
241
|
+
"packages": rows,
|
|
242
|
+
}
|
pkgreach/collect.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""The collection run: select packages per platform, fetch, merge into the history.
|
|
2
|
+
|
|
3
|
+
:func:`collect` is the one orchestrator. Every table merges idempotently, so a
|
|
4
|
+
cold start and a weekly update are the same call, and running it twice in a
|
|
5
|
+
row changes nothing. One package failing never sinks a run: its error goes to
|
|
6
|
+
``last_run.json`` and the rest is collected.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Mapping, MutableMapping
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from datetime import date, datetime, timedelta, timezone
|
|
18
|
+
from fnmatch import fnmatch
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pkgreach.config import DFLT_DATA_DIR, Selection, Settings
|
|
22
|
+
from pkgreach.http import Http, make_http
|
|
23
|
+
from pkgreach.platforms import PLATFORMS, Platform
|
|
24
|
+
from pkgreach.platforms.ecosystems import dependents as ecosystems_dependents
|
|
25
|
+
from pkgreach.tables import (
|
|
26
|
+
DATA_LICENSE,
|
|
27
|
+
Files,
|
|
28
|
+
dump_csv,
|
|
29
|
+
parse_csv,
|
|
30
|
+
read_table,
|
|
31
|
+
table_files,
|
|
32
|
+
write_rows,
|
|
33
|
+
DATA_LICENSE_KEY,
|
|
34
|
+
LAST_RUN_KEY,
|
|
35
|
+
MANIFEST_KEY,
|
|
36
|
+
SCHEMA_VERSION,
|
|
37
|
+
TABLES,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger("pkgreach")
|
|
41
|
+
#: Days re-fetched before the last collected day: registries revise recent days.
|
|
42
|
+
DFLT_OVERLAP_DAYS = 7
|
|
43
|
+
|
|
44
|
+
Select = Callable[[str, Mapping[str, Any]], bool]
|
|
45
|
+
Dependents = Callable[..., dict[str, int]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RunReport:
|
|
50
|
+
"""What a run did, for logs and ``last_run.json``."""
|
|
51
|
+
|
|
52
|
+
started_at: str
|
|
53
|
+
duration_s: float = 0.0
|
|
54
|
+
packages: dict[str, int] = field(default_factory=dict)
|
|
55
|
+
errors: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
files_changed: int = 0
|
|
57
|
+
|
|
58
|
+
def summary(self) -> str:
|
|
59
|
+
"""One line for the log."""
|
|
60
|
+
per = ", ".join(f"{p} {n}" for p, n in self.packages.items()) or "nothing"
|
|
61
|
+
return (
|
|
62
|
+
f"pkgreach: {per} packages; {len(self.errors)} errors, "
|
|
63
|
+
f"{self.files_changed} files changed in {self.duration_s:.1f}s"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _matches(record: Mapping, where: Mapping) -> bool:
|
|
68
|
+
return all(
|
|
69
|
+
not k.startswith("_")
|
|
70
|
+
and k in record
|
|
71
|
+
and str(record[k]).lower() == str(v).lower()
|
|
72
|
+
for k, v in where.items()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def select_packages(
|
|
77
|
+
platform: Platform,
|
|
78
|
+
selection: Selection,
|
|
79
|
+
*,
|
|
80
|
+
http: Http,
|
|
81
|
+
select: Select | None = None,
|
|
82
|
+
on_error: Callable[[str, Exception], None] = lambda *_: None,
|
|
83
|
+
) -> dict[str, dict]:
|
|
84
|
+
"""The packages a selection names, as ``{name: metadata record}``.
|
|
85
|
+
|
|
86
|
+
The users' packages that match ``where``, plus the named ones (named
|
|
87
|
+
explicitly, so ``where`` does not apply to them), minus ``exclude``
|
|
88
|
+
patterns, keeping those ``select`` accepts. Names are normalized by the
|
|
89
|
+
platform (PyPI: ``Flask_Login`` and ``flask-login`` are one package).
|
|
90
|
+
"""
|
|
91
|
+
norm = platform.normalize
|
|
92
|
+
records: dict[str, dict] = {}
|
|
93
|
+
for user in selection.users:
|
|
94
|
+
try:
|
|
95
|
+
for r in platform.list_packages(user, http=http):
|
|
96
|
+
if _matches(r, selection.where):
|
|
97
|
+
records.setdefault(
|
|
98
|
+
norm(r["package"]), {**r, "package": norm(r["package"])}
|
|
99
|
+
)
|
|
100
|
+
except Exception as e: # noqa: BLE001 - one account never sinks the run
|
|
101
|
+
on_error(f"{platform.name}:@{user}", e)
|
|
102
|
+
for name in selection.packages:
|
|
103
|
+
records.setdefault(norm(name), {"package": norm(name)})
|
|
104
|
+
return {
|
|
105
|
+
name: r
|
|
106
|
+
for name, r in sorted(records.items())
|
|
107
|
+
if not any(fnmatch(name, pat) for pat in selection.exclude)
|
|
108
|
+
and (select is None or select(platform.name, r))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _last_dates(store: MutableMapping, platform: str) -> dict[str, str]:
|
|
113
|
+
"""The last day with downloads, per package, from the last two year files."""
|
|
114
|
+
files = table_files(store, TABLES["downloads"])[-2:]
|
|
115
|
+
years = {f.split("/")[-1][:4] for f in files}
|
|
116
|
+
last: dict[str, str] = {}
|
|
117
|
+
for r in read_table(store, TABLES["downloads"], years=years):
|
|
118
|
+
if r["platform"] == platform:
|
|
119
|
+
last[r["package"]] = max(last.get(r["package"], ""), str(r["date"]))
|
|
120
|
+
return last
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _prune(store: MutableMapping, platform: str, *, keep: set[str]) -> int:
|
|
124
|
+
"""Remove a platform's packages that are no longer followed from the
|
|
125
|
+
``packages`` and ``metrics`` tables (a repo made private must disappear
|
|
126
|
+
from a public history). Returns the number of files changed."""
|
|
127
|
+
changed = 0
|
|
128
|
+
for name in ("packages", "metrics"):
|
|
129
|
+
spec = TABLES[name]
|
|
130
|
+
for path in table_files(store, spec):
|
|
131
|
+
text = store[path]
|
|
132
|
+
rows = parse_csv(spec, text)
|
|
133
|
+
kept = [
|
|
134
|
+
r for r in rows if r["platform"] != platform or r["package"] in keep
|
|
135
|
+
]
|
|
136
|
+
if len(kept) != len(rows):
|
|
137
|
+
store[path] = dump_csv(spec, kept)
|
|
138
|
+
changed += 1
|
|
139
|
+
return changed
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def write_manifest(store: MutableMapping, *, updated: str) -> dict:
|
|
143
|
+
"""Write ``manifest.json``: the schema and the files of every table."""
|
|
144
|
+
manifest = {
|
|
145
|
+
"schema_version": SCHEMA_VERSION,
|
|
146
|
+
"updated": updated,
|
|
147
|
+
"tables": {
|
|
148
|
+
name: {
|
|
149
|
+
"files": table_files(store, spec),
|
|
150
|
+
"columns": list(spec.columns),
|
|
151
|
+
"key": list(spec.key),
|
|
152
|
+
"description": spec.description,
|
|
153
|
+
}
|
|
154
|
+
for name, spec in TABLES.items()
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
text = json.dumps(manifest, indent=2) + "\n"
|
|
158
|
+
if store.get(MANIFEST_KEY) != text:
|
|
159
|
+
store[MANIFEST_KEY] = text
|
|
160
|
+
return manifest
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def collect(
|
|
164
|
+
settings: Settings,
|
|
165
|
+
*,
|
|
166
|
+
store: MutableMapping | None = None,
|
|
167
|
+
platforms: Mapping[str, Platform] = PLATFORMS,
|
|
168
|
+
http: Http | None = None,
|
|
169
|
+
select: Select | None = None,
|
|
170
|
+
dependents: Dependents = ecosystems_dependents,
|
|
171
|
+
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
|
172
|
+
log: Callable[[str], None] = _log.info,
|
|
173
|
+
) -> RunReport:
|
|
174
|
+
"""Run one collection into ``store`` and return its report.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
settings: what to collect (:func:`~pkgreach.config.load_settings`).
|
|
178
|
+
store: where the history lives; default ``~/.local/share/pkgreach/data``.
|
|
179
|
+
platforms: name to :class:`~pkgreach.platforms.Platform`; add one here
|
|
180
|
+
to support a new registry without touching anything else.
|
|
181
|
+
http: the transport (default :func:`~pkgreach.http.make_http`).
|
|
182
|
+
select: ``(platform, metadata) -> keep?``, on top of the settings.
|
|
183
|
+
dependents: ``(ecosystem, package, *, http) -> {metric: count}``; the
|
|
184
|
+
default asks ecosyste.ms for dependent packages and repositories.
|
|
185
|
+
now: the clock; log: where the one-line summary goes.
|
|
186
|
+
"""
|
|
187
|
+
store = Files(DFLT_DATA_DIR) if store is None else store
|
|
188
|
+
http = http or make_http()
|
|
189
|
+
t0 = time.monotonic()
|
|
190
|
+
started = now()
|
|
191
|
+
today = started.date()
|
|
192
|
+
report = RunReport(started_at=started.isoformat(timespec="seconds"))
|
|
193
|
+
rows: dict[str, list[dict]] = {name: [] for name in TABLES}
|
|
194
|
+
old_packages = {
|
|
195
|
+
(r["platform"], r["package"]): r for r in read_table(store, TABLES["packages"])
|
|
196
|
+
}
|
|
197
|
+
unknown = sorted(set(settings.platforms) - set(platforms))
|
|
198
|
+
if unknown:
|
|
199
|
+
raise ValueError(f"Unknown platform(s) {unknown}; known: {sorted(platforms)}")
|
|
200
|
+
|
|
201
|
+
def on_error(where: str, e: Exception) -> None:
|
|
202
|
+
report.errors[where] = f"{type(e).__name__}: {e}"
|
|
203
|
+
|
|
204
|
+
for pname, selection in settings.platforms.items():
|
|
205
|
+
platform = platforms[pname]
|
|
206
|
+
chosen = select_packages(
|
|
207
|
+
platform, selection, http=http, select=select, on_error=on_error
|
|
208
|
+
)
|
|
209
|
+
last = _last_dates(store, pname)
|
|
210
|
+
|
|
211
|
+
def one(item: tuple[str, dict]):
|
|
212
|
+
name, record = item
|
|
213
|
+
since = None
|
|
214
|
+
if name in last:
|
|
215
|
+
since = date.fromisoformat(last[name]) - timedelta(DFLT_OVERLAP_DAYS)
|
|
216
|
+
got = platform.fetch(record, http=http, today=today, since=since)
|
|
217
|
+
if settings.dependents and platform.ecosystem:
|
|
218
|
+
try: # a dependents outage never costs the downloads
|
|
219
|
+
deps = dependents(platform.ecosystem, name, http=http)
|
|
220
|
+
got["metrics"] = {**deps, **got.get("metrics", {})}
|
|
221
|
+
except Exception as e: # noqa: BLE001 - recorded, run goes on
|
|
222
|
+
got["dependents_error"] = e
|
|
223
|
+
return got
|
|
224
|
+
|
|
225
|
+
def safely(item):
|
|
226
|
+
try:
|
|
227
|
+
return item[0], one(item), None
|
|
228
|
+
except Exception as e: # noqa: BLE001 - recorded per package
|
|
229
|
+
return item[0], None, e
|
|
230
|
+
|
|
231
|
+
n_ok = n_err = 0
|
|
232
|
+
listing_ok = not any(k.startswith(f"{pname}:@") for k in report.errors)
|
|
233
|
+
with ThreadPoolExecutor(max_workers=max(1, settings.max_workers)) as pool:
|
|
234
|
+
for name, got, err in pool.map(safely, chosen.items()):
|
|
235
|
+
old = old_packages.get((pname, name), {})
|
|
236
|
+
if err is not None:
|
|
237
|
+
on_error(f"{pname}:{name}", err)
|
|
238
|
+
n_err += 1
|
|
239
|
+
# still followed: keep it current, with what we knew
|
|
240
|
+
rows["packages"].append(
|
|
241
|
+
{
|
|
242
|
+
**old,
|
|
243
|
+
"platform": pname,
|
|
244
|
+
"package": name,
|
|
245
|
+
"first_seen": old.get("first_seen") or today.isoformat(),
|
|
246
|
+
"last_seen": today.isoformat(),
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
continue
|
|
250
|
+
n_ok += 1
|
|
251
|
+
if "dependents_error" in got:
|
|
252
|
+
on_error(f"{pname}:{name}:dependents", got["dependents_error"])
|
|
253
|
+
rows["downloads"] += [
|
|
254
|
+
{"platform": pname, "package": name, **r}
|
|
255
|
+
for r in got.get("downloads", [])
|
|
256
|
+
if r["count"]
|
|
257
|
+
]
|
|
258
|
+
rows["metrics"] += [
|
|
259
|
+
{"date": today.isoformat(), "platform": pname, "package": name}
|
|
260
|
+
| {"metric": m, "value": v}
|
|
261
|
+
for m, v in got.get("metrics", {}).items()
|
|
262
|
+
if v is not None
|
|
263
|
+
]
|
|
264
|
+
fetched_from = got.get("history_from", "")
|
|
265
|
+
rows["packages"].append(
|
|
266
|
+
{
|
|
267
|
+
**got.get("info", {}),
|
|
268
|
+
"platform": pname,
|
|
269
|
+
"package": name,
|
|
270
|
+
"history_from": min(
|
|
271
|
+
filter(None, [old.get("history_from"), fetched_from]),
|
|
272
|
+
default="",
|
|
273
|
+
),
|
|
274
|
+
"first_seen": old.get("first_seen") or today.isoformat(),
|
|
275
|
+
"last_seen": today.isoformat(),
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
report.packages[pname] = n_ok
|
|
279
|
+
if platform.prune_unlisted and listing_ok:
|
|
280
|
+
report.files_changed += _prune(store, pname, keep=set(chosen))
|
|
281
|
+
rows["runs"].append(
|
|
282
|
+
{
|
|
283
|
+
"started_at": report.started_at,
|
|
284
|
+
"platform": pname,
|
|
285
|
+
"packages": n_ok,
|
|
286
|
+
"errors": n_err,
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
report.duration_s = round(time.monotonic() - t0, 1)
|
|
291
|
+
for r in rows["runs"]:
|
|
292
|
+
r["duration_s"] = report.duration_s
|
|
293
|
+
for name, spec in TABLES.items():
|
|
294
|
+
if rows[name]:
|
|
295
|
+
report.files_changed += write_rows(store, spec, rows[name])
|
|
296
|
+
store[LAST_RUN_KEY] = json.dumps(asdict(report), indent=2) + "\n"
|
|
297
|
+
if store.get(DATA_LICENSE_KEY) != DATA_LICENSE:
|
|
298
|
+
store[DATA_LICENSE_KEY] = DATA_LICENSE
|
|
299
|
+
write_manifest(store, updated=today.isoformat())
|
|
300
|
+
log(report.summary())
|
|
301
|
+
return report
|