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/config.py ADDED
@@ -0,0 +1,120 @@
1
+ """Settings: which packages, on which platforms.
2
+
3
+ A ``pkgreach.toml`` has one table per platform, each a :class:`Selection`::
4
+
5
+ [pypi]
6
+ users = ["alice"] # every package these accounts maintain
7
+ packages = ["some-other-package"] # plus these
8
+ exclude = ["alice-test-*"] # minus these (glob patterns)
9
+
10
+ [github]
11
+ users = ["alice", "alice-org"] # repos of these owners (public only)
12
+ where = { fork = false, archived = false } # characteristics to keep
13
+
14
+ Every key is optional and unknown keys are refused, so a typo never silently
15
+ falls back to a default. In Python, ``collect(select=...)`` adds a predicate
16
+ over each package's metadata, for selections a TOML file cannot express.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass, field, fields
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import tomllib
28
+
29
+ DFLT_CONFIG_FILENAME = "pkgreach.toml"
30
+ DFLT_DATA_DIR = (
31
+ Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
32
+ / "pkgreach"
33
+ / "data"
34
+ )
35
+ #: Top-level keys that are run options, not platforms.
36
+ RUN_KEYS = ("max_workers", "dependents")
37
+
38
+
39
+ class SettingsError(ValueError):
40
+ """A settings file that is missing or wrong (a user error, not a bug)."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Selection:
45
+ """Which packages of one platform to follow.
46
+
47
+ Attributes:
48
+ users: accounts (users, maintainers, orgs) whose packages are all followed.
49
+ packages: packages followed by name, in addition to the users' ones.
50
+ exclude: glob patterns of package names to drop (``"*-test"``).
51
+ where: metadata a package must match to be kept, field by field
52
+ (``{"fork": False}``). Fields a platform does not report never match.
53
+ """
54
+
55
+ users: tuple[str, ...] = ()
56
+ packages: tuple[str, ...] = ()
57
+ exclude: tuple[str, ...] = ()
58
+ where: Mapping[str, Any] = field(default_factory=dict)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Settings:
63
+ """What to collect: a :class:`Selection` per platform, and run options.
64
+
65
+ Attributes:
66
+ platforms: platform name to selection; a platform absent here is skipped.
67
+ max_workers: concurrent requests per platform.
68
+ dependents: also record dependent packages and repositories
69
+ (from ecosyste.ms), the strongest sign of real use.
70
+ """
71
+
72
+ platforms: Mapping[str, Selection] = field(default_factory=dict)
73
+ max_workers: int = 4
74
+ dependents: bool = True
75
+
76
+
77
+ def _tuple(v) -> tuple[str, ...]:
78
+ return (v,) if isinstance(v, str) else tuple(v)
79
+
80
+
81
+ def _selection(platform: str, d: Mapping) -> Selection:
82
+ known = [f.name for f in fields(Selection)]
83
+ unknown = sorted(set(d) - set(known))
84
+ if unknown:
85
+ raise SettingsError(
86
+ f"Unknown key(s) {unknown} in [{platform}]; valid keys are {known}."
87
+ )
88
+ kw = {k: _tuple(d[k]) for k in ("users", "packages", "exclude") if k in d}
89
+ return Selection(**kw, where=dict(d.get("where", {})))
90
+
91
+
92
+ def settings_from_mapping(d: Mapping, *, known_platforms=None) -> Settings:
93
+ """Settings from a parsed TOML document (or any nested mapping)."""
94
+ d = dict(d.get("pkgreach", d))
95
+ run = {k: d.pop(k) for k in RUN_KEYS if k in d}
96
+ if known_platforms is not None:
97
+ unknown = sorted(set(d) - set(known_platforms))
98
+ if unknown:
99
+ raise SettingsError(
100
+ f"Unknown platform(s) or setting(s) {unknown}; platforms are "
101
+ f"{sorted(known_platforms)}, settings are {list(RUN_KEYS)}."
102
+ )
103
+ return Settings(
104
+ platforms={name: _selection(name, sel) for name, sel in d.items()}, **run
105
+ )
106
+
107
+
108
+ def load_settings(path: str | os.PathLike, *, known_platforms=None) -> Settings:
109
+ """Settings from a TOML file; a missing or invalid file raises :class:`SettingsError`."""
110
+ p = Path(path)
111
+ if not p.exists():
112
+ raise SettingsError(
113
+ f"No settings file at {p}. Write one (`pkgreach init`) that lists "
114
+ 'your accounts per platform, e.g. [pypi] users = ["you"].'
115
+ )
116
+ try:
117
+ doc = tomllib.loads(p.read_text(encoding="utf-8"))
118
+ except tomllib.TOMLDecodeError as e:
119
+ raise SettingsError(f"{p} is not valid TOML: {e}") from None
120
+ return settings_from_mapping(doc, known_platforms=known_platforms)
pkgreach/http.py ADDED
@@ -0,0 +1,129 @@
1
+ """The HTTP transport: JSON over the standard library, polite to public APIs.
2
+
3
+ Every platform takes an ``http=`` callable, ``(url, *, headers=None,
4
+ data=None) -> parsed JSON`` (or text, for non-JSON responses; ``data`` makes it
5
+ a POST), so tests and caches can stand in.
6
+ The default, :func:`make_http`, uses :mod:`urllib`, sends an identifying
7
+ ``User-Agent`` (crates.io refuses requests without one), retries rate limits
8
+ and server errors with backoff, and spaces calls to the same host.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import http.client
14
+ import json
15
+ import threading
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from collections.abc import Callable, Mapping
21
+ from typing import Any
22
+
23
+ Http = Callable[..., Any]
24
+
25
+ DFLT_USER_AGENT = "pkgreach (+https://github.com/thorwhalen/pkgreach)"
26
+ DFLT_TIMEOUT = 30
27
+ DFLT_RETRIES = 6
28
+ DFLT_BACKOFF_S = 2.0
29
+ #: Minimum seconds between two calls to the same host: crates.io asks for one a
30
+ #: second, pypistats.org allows 60 a minute, api.npmjs.org answers bursts with 429.
31
+ DFLT_MIN_INTERVAL_S = {"crates.io": 1.0, "pypistats.org": 1.0, "api.npmjs.org": 2.0}
32
+ MAX_RETRY_WAIT_S = 120.0
33
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
34
+
35
+
36
+ class _SameHostAuthRedirect(urllib.request.HTTPRedirectHandler):
37
+ """Follow redirects, but never carry ``Authorization`` to another host."""
38
+
39
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
40
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
41
+ old_host = urllib.parse.urlsplit(req.full_url).hostname
42
+ if new is not None and urllib.parse.urlsplit(newurl).hostname != old_host:
43
+ new.remove_header("Authorization")
44
+ return new
45
+
46
+
47
+ _OPENER = urllib.request.build_opener(_SameHostAuthRedirect)
48
+
49
+
50
+ class HttpError(RuntimeError):
51
+ """An HTTP call that failed, with its status (0 for network errors)."""
52
+
53
+ def __init__(self, status: int, message: str, *, url: str = ""):
54
+ super().__init__(f"HTTP {status} on {url}: {message}")
55
+ self.status, self.message, self.url = status, message, url
56
+
57
+
58
+ def _retry_after(e: urllib.error.HTTPError, attempt: int, backoff_s: float) -> float:
59
+ """Exponential backoff, longer if the server asks (npm sends ``Retry-After: 0``)."""
60
+ try:
61
+ asked = float(e.headers.get("Retry-After", ""))
62
+ except ValueError:
63
+ asked = 0.0
64
+ return min(max(asked, backoff_s * 2**attempt), MAX_RETRY_WAIT_S)
65
+
66
+
67
+ def make_http(
68
+ *,
69
+ user_agent: str = DFLT_USER_AGENT,
70
+ timeout: float = DFLT_TIMEOUT,
71
+ retries: int = DFLT_RETRIES,
72
+ backoff_s: float = DFLT_BACKOFF_S,
73
+ min_interval_s: Mapping[str, float] = DFLT_MIN_INTERVAL_S,
74
+ headers_by_host: Mapping[str, Mapping[str, str]] | None = None,
75
+ sleep: Callable[[float], None] = time.sleep,
76
+ ) -> Http:
77
+ """A ``get(url, *, headers=None, data=None)`` returning parsed JSON (else text).
78
+
79
+ ``headers_by_host`` adds headers to every request to a host: the place for
80
+ credentials, so that platforms never read them from the environment.
81
+ """
82
+ last_call: dict[str, float] = {}
83
+ host_locks: dict[str, threading.Lock] = {}
84
+ locks_lock = threading.Lock()
85
+
86
+ def _space(host: str) -> None:
87
+ """Wait until ``host`` may be called again (hosts never wait on each other)."""
88
+ interval = min_interval_s.get(host, 0.0)
89
+ if not interval:
90
+ return
91
+ with locks_lock:
92
+ lock = host_locks.setdefault(host, threading.Lock())
93
+ with lock:
94
+ wait = last_call.get(host, 0.0) + interval - time.monotonic()
95
+ if wait > 0:
96
+ sleep(wait)
97
+ last_call[host] = time.monotonic()
98
+
99
+ def get(
100
+ url: str, *, headers: Mapping[str, str] | None = None, data: bytes | None = None
101
+ ) -> Any:
102
+ host = urllib.parse.urlsplit(url).hostname or ""
103
+ hdrs = {"User-Agent": user_agent, "Accept": "application/json"}
104
+ hdrs.update((headers_by_host or {}).get(host, {})) # credentials, per host
105
+ hdrs.update(headers or {})
106
+ for attempt in range(retries + 1):
107
+ _space(host)
108
+ req = urllib.request.Request(url, headers=hdrs, data=data)
109
+ try:
110
+ with _OPENER.open(req, timeout=timeout) as resp:
111
+ body = resp.read().decode("utf-8")
112
+ except urllib.error.HTTPError as e:
113
+ if e.code in _RETRY_STATUSES and attempt < retries:
114
+ sleep(_retry_after(e, attempt, backoff_s))
115
+ continue
116
+ raise HttpError(e.code, e.reason or "", url=url) from None
117
+ except (urllib.error.URLError, OSError, http.client.HTTPException) as e:
118
+ # network trouble, including resets and truncated reads
119
+ if attempt < retries:
120
+ sleep(backoff_s * 2**attempt)
121
+ continue
122
+ raise HttpError(0, str(e), url=url) from None
123
+ try:
124
+ return json.loads(body)
125
+ except json.JSONDecodeError:
126
+ return body
127
+ raise AssertionError("unreachable") # pragma: no cover
128
+
129
+ return get
@@ -0,0 +1,103 @@
1
+ """Platforms: where packages are published, and how to read their numbers.
2
+
3
+ A :class:`Platform` says how to list an account's packages, how to fetch one
4
+ package's numbers, and how the report should present them. :data:`PLATFORMS`
5
+ holds the built-in ones; pass your own mapping to :func:`pkgreach.collect`
6
+ and :func:`pkgreach.summarize` to add one (conda-forge, Docker Hub, Hugging
7
+ Face, ...). Nothing else changes: the analysis and the report read only the
8
+ fields below, never a platform's name.
9
+
10
+ ``list_packages(user, *, http)`` yields metadata records with at least a
11
+ ``package`` key. Other plain keys (``fork``, ``role``, ...) can be matched by
12
+ a selection's ``where``. A listing that already carries the numbers may put
13
+ them under ``_info`` and ``_metrics``, so that ``fetch`` needs no further call
14
+ (GitHub does); keys starting with ``_`` are never matched by ``where``.
15
+
16
+ ``fetch(record, *, http, today, since)`` returns a dict with any of:
17
+
18
+ - ``downloads``: ``[{"date", "series", "count"}, ...]`` daily counts from
19
+ ``since`` (``None``: as far back as the platform keeps them);
20
+ - ``history_from``: the first day the source covered (ISO date), so absent
21
+ days after it read as zero rather than unknown;
22
+ - ``metrics``: ``{name: int}`` current values (stars, versions, total downloads);
23
+ - ``info``: metadata for the ``packages`` table (``url``, ``repository``,
24
+ ``description``, ``latest_version``, ``created``).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Callable, Iterable, Mapping
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Literal
32
+
33
+ from pkgreach.platforms import crates, github, npm, pypi
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Platform:
38
+ """How to list and read the packages of one publishing platform.
39
+
40
+ Attributes:
41
+ name: the key in ``pkgreach.toml`` and in the tables' ``platform`` column.
42
+ list_packages, fetch: see the module docstring.
43
+ title: how the report names it.
44
+ kind: ``"daily"`` if ``fetch`` returns daily downloads (the report shows
45
+ windows, growth and the noise floor), ``"snapshot"`` if it returns
46
+ current values only (the report shows ``headline`` metrics).
47
+ headline: the metrics a snapshot platform's table and card show, the
48
+ first one summed on the card.
49
+ preferred_series: download series to analyse, best first.
50
+ ecosystem: the package ecosystem, as a purl type (``pypi``, ``npm``,
51
+ ``cargo``), for dependents lookups; ``""`` for none.
52
+ default_where: the ``where`` that ``pkgreach init`` writes for it.
53
+ normalize: canonical form of a package name (PyPI: PEP 503).
54
+ prune_unlisted: remove packages no longer listed from the history's
55
+ current tables (for GitHub: a repo made private must not stay in a
56
+ public history).
57
+ """
58
+
59
+ name: str
60
+ list_packages: Callable[..., Iterable[dict]]
61
+ fetch: Callable[..., dict[str, Any]]
62
+ title: str = ""
63
+ kind: Literal["daily", "snapshot"] = "daily"
64
+ headline: tuple[str, ...] = ()
65
+ preferred_series: tuple[str, ...] = ("all",)
66
+ ecosystem: str = ""
67
+ default_where: Mapping[str, Any] = field(default_factory=dict)
68
+ normalize: Callable[[str], str] = str
69
+ prune_unlisted: bool = False
70
+
71
+
72
+ PLATFORMS: dict[str, Platform] = {
73
+ p.name: p
74
+ for p in [
75
+ Platform(
76
+ "pypi",
77
+ pypi.list_packages,
78
+ pypi.fetch,
79
+ title="PyPI",
80
+ preferred_series=("without_mirrors", "all"),
81
+ ecosystem="pypi",
82
+ normalize=pypi.normalize,
83
+ ),
84
+ Platform("npm", npm.list_packages, npm.fetch, title="npm", ecosystem="npm"),
85
+ Platform(
86
+ "crates",
87
+ crates.list_packages,
88
+ crates.fetch,
89
+ title="crates.io",
90
+ ecosystem="cargo",
91
+ ),
92
+ Platform(
93
+ "github",
94
+ github.list_packages,
95
+ github.fetch,
96
+ title="GitHub",
97
+ kind="snapshot",
98
+ headline=("stars", "forks", "open_issues"),
99
+ default_where={"fork": False, "archived": False},
100
+ prune_unlisted=True,
101
+ ),
102
+ ]
103
+ }
@@ -0,0 +1,67 @@
1
+ """crates.io: daily downloads (last 90 days), totals and metadata.
2
+
3
+ - **Listing** an account's crates: ``/api/v1/users/{login}`` gives the user id,
4
+ then ``/api/v1/crates?user_id={id}`` (100 per page).
5
+ - **Downloads**: ``/api/v1/crates/{name}/downloads``: per version and day for
6
+ the last 90 days, plus ``extra_downloads`` for the older versions; summed
7
+ per day.
8
+ - **Metadata** and all-time totals: ``/api/v1/crates/{name}``.
9
+
10
+ crates.io requires an identifying User-Agent and at most one request a second
11
+ (see :mod:`pkgreach.http`, which does both).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from collections.abc import Iterator
18
+ from datetime import date
19
+
20
+ API = "https://crates.io/api/v1"
21
+ PAGE_SIZE = 100
22
+
23
+
24
+ def list_packages(user: str, *, http) -> Iterator[dict]:
25
+ """The crates ``user`` (a crates.io login) owns."""
26
+ uid = http(f"{API}/users/{user}")["user"]["id"]
27
+ page = 1
28
+ while True:
29
+ got = http(f"{API}/crates?user_id={uid}&per_page={PAGE_SIZE}&page={page}")
30
+ for c in got.get("crates", []):
31
+ yield {"package": c["name"]}
32
+ if not got.get("meta", {}).get("next_page"):
33
+ return
34
+ page += 1
35
+
36
+
37
+ def fetch(record: dict, *, http, today: date, since: date | None = None) -> dict:
38
+ """Daily downloads, totals and metadata of one crate."""
39
+ name = record["package"]
40
+ got = http(f"{API}/crates/{name}/downloads")
41
+ per_day: Counter = Counter()
42
+ for r in got.get("version_downloads", []) + got.get("meta", {}).get(
43
+ "extra_downloads", []
44
+ ):
45
+ per_day[r["date"]] += r["downloads"]
46
+ lo = since.isoformat() if since else ""
47
+ crate = http(f"{API}/crates/{name}")
48
+ c = crate.get("crate", {})
49
+ return {
50
+ "downloads": [
51
+ {"date": d, "series": "all", "count": n}
52
+ for d, n in sorted(per_day.items())
53
+ if d >= lo
54
+ ],
55
+ "history_from": min(per_day, default=""),
56
+ "metrics": {
57
+ "total_downloads": c.get("downloads"),
58
+ "versions": len(crate.get("versions") or []) or None,
59
+ },
60
+ "info": {
61
+ "url": f"https://crates.io/crates/{name}",
62
+ "repository": c.get("repository") or "",
63
+ "description": (c.get("description") or "").strip(),
64
+ "latest_version": c.get("max_stable_version") or c.get("max_version") or "",
65
+ "created": (c.get("created_at") or "")[:10],
66
+ },
67
+ }
@@ -0,0 +1,36 @@
1
+ """Dependents, from ecosyste.ms: the strongest public sign that a package is used.
2
+
3
+ A download can be a mirror or a CI cache miss; a dependent is another package
4
+ or repository that declares yours in its manifest. ecosyste.ms (open data,
5
+ CC-BY-SA 4.0, no key needed) indexes them for every major registry:
6
+ ``GET /api/v1/registries/{registry}/packages/{name}`` carries
7
+ ``dependent_packages_count`` and ``dependent_repos_count``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import urllib.parse
13
+
14
+ from pkgreach.http import HttpError
15
+
16
+ API = "https://packages.ecosyste.ms/api/v1"
17
+ #: Ecosystem (purl type) to ecosyste.ms registry name.
18
+ REGISTRIES = {"pypi": "pypi.org", "npm": "npmjs.org", "cargo": "crates.io"}
19
+
20
+
21
+ def dependents(ecosystem: str, package: str, *, http) -> dict[str, int]:
22
+ """``{"dependent_packages": n, "dependent_repos": m}``; ``{}`` if not indexed."""
23
+ registry = REGISTRIES.get(ecosystem)
24
+ if registry is None:
25
+ return {}
26
+ name = urllib.parse.quote(package, safe="")
27
+ try:
28
+ d = http(f"{API}/registries/{registry}/packages/{name}")
29
+ except HttpError as e:
30
+ if e.status == 404: # not indexed (yet): unknown, not zero
31
+ return {}
32
+ raise
33
+ return {
34
+ "dependent_packages": d.get("dependent_packages_count"),
35
+ "dependent_repos": d.get("dependent_repos_count"),
36
+ }
@@ -0,0 +1,84 @@
1
+ """GitHub: public repositories, with stars, forks and open issues.
2
+
3
+ Only **public** repositories are followed, whatever the token can see: this
4
+ report is meant to be publishable. Traffic (views, clones, referrers) needs
5
+ push access and is private; that is ghtraffic's job
6
+ (https://github.com/thorwhalen/ghtraffic), kept in a private repository.
7
+
8
+ Listing uses ``GET /users/{owner}/repos`` (users and organizations alike), one
9
+ call per 100 repos, and the listing already carries the numbers, so fetching
10
+ costs no further call. A token only raises the rate limit (60 calls an hour
11
+ without one); it reaches the requests through the transport
12
+ (``make_http(headers_by_host=auth_headers(token))``), never from here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterator, Mapping
18
+ from datetime import date
19
+
20
+ API = "https://api.github.com"
21
+ HOST = "api.github.com"
22
+ PAGE_SIZE = 100
23
+ TOKEN_ENV_VARS = ("GITHUB_TOKEN", "GH_TOKEN")
24
+ _HEADERS = {
25
+ "Accept": "application/vnd.github+json",
26
+ "X-GitHub-Api-Version": "2022-11-28",
27
+ }
28
+
29
+
30
+ def auth_headers(token: str | None) -> dict[str, Mapping[str, str]]:
31
+ """``headers_by_host`` for :func:`pkgreach.http.make_http`: the token, for GitHub only."""
32
+ return {HOST: {"Authorization": f"Bearer {token}"}} if token else {}
33
+
34
+
35
+ def token_from_env(environ: Mapping[str, str]) -> str:
36
+ """The first of ``GITHUB_TOKEN``, ``GH_TOKEN`` set in ``environ``, or ``""``."""
37
+ return next((environ[v] for v in TOKEN_ENV_VARS if environ.get(v)), "")
38
+
39
+
40
+ def _record(r: dict) -> dict:
41
+ return {
42
+ "package": r["full_name"],
43
+ "private": r.get("private", False),
44
+ "fork": r.get("fork", False),
45
+ "archived": r.get("archived", False),
46
+ "language": r.get("language") or "",
47
+ "_info": {
48
+ "url": r.get("html_url", ""),
49
+ "repository": r.get("html_url", ""),
50
+ "description": r.get("description") or "",
51
+ "created": (r.get("created_at") or "")[:10],
52
+ },
53
+ "_metrics": {
54
+ "stars": r.get("stargazers_count"),
55
+ "forks": r.get("forks_count"),
56
+ "open_issues": r.get("open_issues_count"), # issues + pull requests
57
+ },
58
+ }
59
+
60
+
61
+ def list_packages(user: str, *, http) -> Iterator[dict]:
62
+ """The public repos owned by ``user`` (a user or an organization)."""
63
+ page = 1
64
+ while True:
65
+ got = http(
66
+ f"{API}/users/{user}/repos?type=owner&per_page={PAGE_SIZE}&page={page}",
67
+ headers=_HEADERS,
68
+ )
69
+ for r in got:
70
+ if not r.get("private", False):
71
+ yield _record(r)
72
+ if len(got) < PAGE_SIZE:
73
+ return
74
+ page += 1
75
+
76
+
77
+ def fetch(record: dict, *, http, today: date, since: date | None = None) -> dict:
78
+ """Stars, forks and open issues of one public repo."""
79
+ if "_metrics" not in record: # named in `packages`, not listed
80
+ r = http(f"{API}/repos/{record['package']}", headers=_HEADERS)
81
+ if r.get("private", False):
82
+ raise ValueError("private repository: not followed (public report)")
83
+ record = _record(r)
84
+ return {"metrics": dict(record["_metrics"]), "info": dict(record["_info"])}
@@ -0,0 +1,90 @@
1
+ """npm: daily downloads from api.npmjs.org, listing from the registry search.
2
+
3
+ - **Listing** an account's packages: the registry search,
4
+ ``registry.npmjs.org/-/v1/search?text=maintainer:{user}`` (250 per page).
5
+ - **Downloads**: ``api.npmjs.org/downloads/range/{start}:{end}/{name}``,
6
+ daily, at most the last 18 months (a longer range is clipped), scoped names
7
+ included. The first run takes all 18 months; later runs take what is new.
8
+ A package never downloaded yet answers 404 there, which reads as zero.
9
+ - **Metadata**: from the search record (version, description, repository).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import urllib.parse
15
+ from collections.abc import Iterator
16
+ from datetime import date, timedelta
17
+
18
+ from pkgreach.http import HttpError
19
+
20
+ SEARCH_URL = "https://registry.npmjs.org/-/v1/search?text={q}&size={n}&from={start}"
21
+ RANGE_URL = "https://api.npmjs.org/downloads/range/{start}:{end}/{name}"
22
+ LATEST_URL = "https://registry.npmjs.org/{name}/latest"
23
+ PAGE_SIZE = 250
24
+ #: The download API serves the last 18 months; ask for a little more.
25
+ MAX_HISTORY_DAYS = 560
26
+ #: What the download API actually keeps (a longer range is clipped to this).
27
+ SERVED_DAYS = 540
28
+
29
+
30
+ def _clean_repo(url: str) -> str:
31
+ url = (url or "").removeprefix("git+")
32
+ url = url.replace("git://", "https://").replace("ssh://git@", "https://")
33
+ return url.removesuffix(".git") if url.startswith("https://") else ""
34
+
35
+
36
+ def list_packages(user: str, *, http) -> Iterator[dict]:
37
+ """The packages ``user`` maintains on npm."""
38
+ q = urllib.parse.quote(f"maintainer:{user}")
39
+ start = 0
40
+ while True:
41
+ got = http(SEARCH_URL.format(q=q, n=PAGE_SIZE, start=start))
42
+ objects = got.get("objects", [])
43
+ for o in objects:
44
+ p = o["package"]
45
+ yield {
46
+ "package": p["name"],
47
+ "_info": {
48
+ "url": f"https://www.npmjs.com/package/{p['name']}",
49
+ "repository": _clean_repo(p.get("links", {}).get("repository")),
50
+ "description": p.get("description") or "",
51
+ "latest_version": p.get("version") or "",
52
+ },
53
+ }
54
+ start += len(objects)
55
+ if not objects or start >= got.get("total", 0):
56
+ return
57
+
58
+
59
+ def fetch(record: dict, *, http, today: date, since: date | None = None) -> dict:
60
+ """Daily downloads and metadata of one package."""
61
+ name = record["package"]
62
+ start = since or today - timedelta(days=MAX_HISTORY_DAYS)
63
+ try:
64
+ got = http(RANGE_URL.format(start=start, end=today, name=name))
65
+ except HttpError as e:
66
+ if e.status != 404:
67
+ raise
68
+ # "package not found" here means: published, but never downloaded yet
69
+ served = max(start, today - timedelta(days=SERVED_DAYS))
70
+ got = {"start": served.isoformat(), "downloads": []}
71
+ info = record.get("_info")
72
+ if info is None: # named in `packages`, not listed
73
+ latest = http(LATEST_URL.format(name=name))
74
+ repo = latest.get("repository")
75
+ info = {
76
+ "url": f"https://www.npmjs.com/package/{name}",
77
+ "repository": _clean_repo(
78
+ repo.get("url") if isinstance(repo, dict) else repo
79
+ ),
80
+ "description": latest.get("description") or "",
81
+ "latest_version": latest.get("version") or "",
82
+ }
83
+ return {
84
+ "downloads": [
85
+ {"date": d["day"], "series": "all", "count": d["downloads"]}
86
+ for d in got.get("downloads", [])
87
+ ],
88
+ "history_from": got.get("start", ""), # the min with earlier runs is kept
89
+ "info": info,
90
+ }