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 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