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/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
|
+
}
|