installgate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contracts/README.md +11 -0
- contracts/__init__.py +31 -0
- contracts/contracts.py +260 -0
- contracts/contracts.ts +151 -0
- engine/README.md +36 -0
- engine/__init__.py +1 -0
- engine/cache.py +64 -0
- engine/checks.py +695 -0
- engine/clients/__init__.py +64 -0
- engine/clients/huggingface.py +69 -0
- engine/clients/npm.py +91 -0
- engine/clients/osv.py +91 -0
- engine/clients/pypi.py +197 -0
- engine/data/pypi-top5000-2026-10-27.txt +5011 -0
- engine/evidence.py +55 -0
- engine/parsers.py +216 -0
- engine/policy.py +153 -0
- engine/policy.yaml +92 -0
- engine/runtime_floors.yaml +67 -0
- engine/stage1.py +137 -0
- installgate-0.1.0.dist-info/METADATA +439 -0
- installgate-0.1.0.dist-info/RECORD +58 -0
- installgate-0.1.0.dist-info/WHEEL +4 -0
- installgate-0.1.0.dist-info/entry_points.txt +3 -0
- installgate-0.1.0.dist-info/licenses/LICENSE +202 -0
- mcp_server/README.md +19 -0
- mcp_server/__init__.py +1 -0
- mcp_server/agent.py +61 -0
- mcp_server/api.py +141 -0
- mcp_server/check.py +412 -0
- mcp_server/cli.py +270 -0
- mcp_server/limit.py +74 -0
- mcp_server/render.py +241 -0
- mcp_server/samples.py +53 -0
- mcp_server/server.py +27 -0
- mcp_server/spend.py +90 -0
- remediate/README.md +18 -0
- remediate/__init__.py +1 -0
- remediate/candidates.py +118 -0
- remediate/diff.py +13 -0
- remediate/pinning.py +27 -0
- remediate/sandbox.py +1 -0
- router/README.md +60 -0
- router/__init__.py +1 -0
- router/compare.py +572 -0
- router/prompts/explain.txt +8 -0
- router/prompts/extract.txt +3 -0
- router/prompts/rank_substitute.txt +1 -0
- router/receipt.py +41 -0
- router/smoke.py +49 -0
- router/tavily.py +129 -0
- router/tiers.py +321 -0
- router/token_factory.py +267 -0
- validator/README.md +11 -0
- validator/__init__.py +1 -0
- validator/citations.py +64 -0
- validator/failclosed.py +53 -0
- validator/lattice.py +52 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Read-only metadata clients. Allowlisted hosts, timeouts, no code execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
ALLOWED_HOSTS = frozenset(
|
|
10
|
+
{
|
|
11
|
+
"pypi.org",
|
|
12
|
+
"registry.npmjs.org",
|
|
13
|
+
"api.osv.dev",
|
|
14
|
+
"huggingface.co",
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
TIMEOUT = httpx.Timeout(connect=3.0, read=5.0, write=3.0, pool=3.0)
|
|
19
|
+
HEADERS = {"User-Agent": "InstallGate/0.1 (metadata-only pre-install check)"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UpstreamError(RuntimeError):
|
|
23
|
+
"""A registry call failed. The caller must fail closed to REVIEW."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _assert_allowed(url: str) -> None:
|
|
27
|
+
host = httpx.URL(url).host
|
|
28
|
+
if host not in ALLOWED_HOSTS:
|
|
29
|
+
raise UpstreamError(f"host not allowlisted: {host}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def get_json(
|
|
33
|
+
client: httpx.AsyncClient, url: str, *, allow_404: bool = False
|
|
34
|
+
) -> dict[str, Any] | None:
|
|
35
|
+
"""GET and parse JSON. Returns None on 404 when allow_404 (used for existence checks)."""
|
|
36
|
+
_assert_allowed(url)
|
|
37
|
+
try:
|
|
38
|
+
response = await client.get(url, timeout=TIMEOUT, headers=HEADERS)
|
|
39
|
+
except httpx.HTTPError as exc:
|
|
40
|
+
raise UpstreamError(f"GET {url} failed: {exc}") from exc
|
|
41
|
+
if response.status_code == 404 and allow_404:
|
|
42
|
+
return None
|
|
43
|
+
if response.status_code >= 400:
|
|
44
|
+
raise UpstreamError(f"GET {url} returned {response.status_code}")
|
|
45
|
+
try:
|
|
46
|
+
body: dict[str, Any] = response.json()
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise UpstreamError(f"GET {url} returned non-JSON") from exc
|
|
49
|
+
return body
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def post_json(client: httpx.AsyncClient, url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
_assert_allowed(url)
|
|
54
|
+
try:
|
|
55
|
+
response = await client.post(url, json=payload, timeout=TIMEOUT, headers=HEADERS)
|
|
56
|
+
except httpx.HTTPError as exc:
|
|
57
|
+
raise UpstreamError(f"POST {url} failed: {exc}") from exc
|
|
58
|
+
if response.status_code >= 400:
|
|
59
|
+
raise UpstreamError(f"POST {url} returned {response.status_code}")
|
|
60
|
+
try:
|
|
61
|
+
body: dict[str, Any] = response.json()
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise UpstreamError(f"POST {url} returned non-JSON") from exc
|
|
64
|
+
return body
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Hugging Face Hub model metadata. We read the file list; we never load weights."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from contracts.contracts import TargetRef
|
|
10
|
+
|
|
11
|
+
from engine.cache import Cache
|
|
12
|
+
from engine.clients import get_json
|
|
13
|
+
|
|
14
|
+
BASE = "https://huggingface.co/api/models"
|
|
15
|
+
|
|
16
|
+
PICKLE_SUFFIXES = (".bin", ".pt", ".pth", ".pkl", ".ckpt")
|
|
17
|
+
SAFE_SUFFIXES = (".safetensors",)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def model_url(repo_id: str) -> str:
|
|
21
|
+
return f"{BASE}/{repo_id}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def summarise(body: dict[str, Any] | None) -> dict[str, Any]:
|
|
25
|
+
if body is None:
|
|
26
|
+
return {"exists": False}
|
|
27
|
+
|
|
28
|
+
filenames = [
|
|
29
|
+
str(s.get("rfilename"))
|
|
30
|
+
for s in (body.get("siblings") or [])
|
|
31
|
+
if isinstance(s, dict) and s.get("rfilename")
|
|
32
|
+
]
|
|
33
|
+
pickle_files = [f for f in filenames if f.lower().endswith(PICKLE_SUFFIXES)]
|
|
34
|
+
safetensors_files = [f for f in filenames if f.lower().endswith(SAFE_SUFFIXES)]
|
|
35
|
+
|
|
36
|
+
created_raw = str(body.get("createdAt") or "")
|
|
37
|
+
modified_raw = str(body.get("lastModified") or "")
|
|
38
|
+
|
|
39
|
+
def _parse(raw: str) -> str | None:
|
|
40
|
+
try:
|
|
41
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(UTC).isoformat()
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"exists": True,
|
|
47
|
+
"files": filenames,
|
|
48
|
+
"pickle_files": pickle_files,
|
|
49
|
+
"safetensors_files": safetensors_files,
|
|
50
|
+
"has_pickle": bool(pickle_files),
|
|
51
|
+
"has_safetensors": bool(safetensors_files),
|
|
52
|
+
"first_upload": _parse(created_raw),
|
|
53
|
+
"latest_upload": _parse(modified_raw),
|
|
54
|
+
"sha": body.get("sha"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def fetch(
|
|
59
|
+
client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
if cache is not None:
|
|
62
|
+
cached = cache.get("huggingface", target.name)
|
|
63
|
+
if cached is not None:
|
|
64
|
+
return dict(cached)
|
|
65
|
+
body = await get_json(client, model_url(target.name), allow_404=True)
|
|
66
|
+
facts = summarise(body)
|
|
67
|
+
if cache is not None:
|
|
68
|
+
cache.set("huggingface", target.name, facts)
|
|
69
|
+
return facts
|
engine/clients/npm.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""npm registry. Existence, `time` map, maintainers, dist integrity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from contracts.contracts import TargetRef
|
|
12
|
+
|
|
13
|
+
from engine.cache import Cache
|
|
14
|
+
from engine.clients import get_json
|
|
15
|
+
|
|
16
|
+
BASE = "https://registry.npmjs.org"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def project_url(name: str) -> str:
|
|
20
|
+
# A scoped name (@scope/pkg) must have its slash encoded.
|
|
21
|
+
return f"{BASE}/{quote(name, safe='@')}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse(raw: str) -> datetime | None:
|
|
25
|
+
try:
|
|
26
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(UTC)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pin_digest(integrity: object, shasum: object, name: str, version: str) -> str:
|
|
32
|
+
"""Stable 64-hex pin from registry metadata. Not the tarball bytes (we never fetch them)."""
|
|
33
|
+
raw = str(integrity or shasum or f"{name}@{version}")
|
|
34
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def summarise(body: dict[str, Any] | None, *, version: str | None = None) -> dict[str, Any]:
|
|
38
|
+
if body is None:
|
|
39
|
+
return {"exists": False}
|
|
40
|
+
|
|
41
|
+
times: dict[str, Any] = body.get("time") or {}
|
|
42
|
+
created = _parse(str(times.get("created") or ""))
|
|
43
|
+
modified = _parse(str(times.get("modified") or ""))
|
|
44
|
+
|
|
45
|
+
latest_version = ((body.get("dist-tags") or {}).get("latest")) or None
|
|
46
|
+
versions: dict[str, Any] = body.get("versions") or {}
|
|
47
|
+
pin_version = version if version and str(version) in versions else latest_version
|
|
48
|
+
pin_meta = versions.get(str(pin_version)) or {}
|
|
49
|
+
dist = pin_meta.get("dist") or {}
|
|
50
|
+
integrity = dist.get("integrity")
|
|
51
|
+
shasum = dist.get("shasum")
|
|
52
|
+
name = str(body.get("name") or pin_meta.get("name") or "")
|
|
53
|
+
|
|
54
|
+
maintainers = sorted(
|
|
55
|
+
{
|
|
56
|
+
str(m.get("name") or m.get("email"))
|
|
57
|
+
for m in (body.get("maintainers") or [])
|
|
58
|
+
if isinstance(m, dict) and (m.get("name") or m.get("email"))
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
pin_sha = _pin_digest(integrity, shasum, name, str(pin_version)) if pin_version else None
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"exists": True,
|
|
66
|
+
"latest_version": latest_version,
|
|
67
|
+
"first_upload": created.isoformat() if created else None,
|
|
68
|
+
"latest_upload": modified.isoformat() if modified else None,
|
|
69
|
+
"maintainers": maintainers,
|
|
70
|
+
"dist_integrity": integrity,
|
|
71
|
+
"dist_shasum": shasum,
|
|
72
|
+
"pin_version": pin_version,
|
|
73
|
+
"pin_sha256": pin_sha,
|
|
74
|
+
"latest_sha256": pin_sha,
|
|
75
|
+
"release_count": len(versions),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def fetch(
|
|
80
|
+
client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
key = f"{target.name}@{target.version or 'latest'}"
|
|
83
|
+
if cache is not None:
|
|
84
|
+
cached = cache.get("npm", key)
|
|
85
|
+
if cached is not None:
|
|
86
|
+
return dict(cached)
|
|
87
|
+
body = await get_json(client, project_url(target.name), allow_404=True)
|
|
88
|
+
facts = summarise(body, version=target.version)
|
|
89
|
+
if cache is not None:
|
|
90
|
+
cache.set("npm", key, facts)
|
|
91
|
+
return facts
|
engine/clients/osv.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""OSV.dev query. Only MAL- records make a package malicious."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from contracts.contracts import Ecosystem, TargetRef
|
|
9
|
+
|
|
10
|
+
from engine.cache import Cache
|
|
11
|
+
from engine.clients import post_json
|
|
12
|
+
|
|
13
|
+
QUERY_URL = "https://api.osv.dev/v1/query"
|
|
14
|
+
|
|
15
|
+
OSV_ECOSYSTEM = {
|
|
16
|
+
Ecosystem.PYPI: "PyPI",
|
|
17
|
+
Ecosystem.NPM: "npm",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def affected_spec(record: dict[str, Any], name: str | None = None) -> dict[str, Any]:
|
|
22
|
+
"""What an advisory says about *which versions* it covers.
|
|
23
|
+
|
|
24
|
+
A MAL- record usually lists exact versions. Some cover the whole project and
|
|
25
|
+
name none. The difference decides whether a clean release of a package that
|
|
26
|
+
was once compromised may still be installed, so it has to survive into the
|
|
27
|
+
evidence rather than collapsing to a boolean.
|
|
28
|
+
"""
|
|
29
|
+
versions: list[str] = []
|
|
30
|
+
ranges: list[dict[str, Any]] = []
|
|
31
|
+
for affected in record.get("affected") or []:
|
|
32
|
+
package = (affected or {}).get("package") or {}
|
|
33
|
+
if name and package.get("name") and str(package["name"]).lower() != name.lower():
|
|
34
|
+
continue
|
|
35
|
+
versions.extend(str(v) for v in (affected.get("versions") or []))
|
|
36
|
+
ranges.extend(r for r in (affected.get("ranges") or []) if isinstance(r, dict))
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"id": str(record.get("id", "")),
|
|
40
|
+
"versions": sorted(set(versions)),
|
|
41
|
+
"ranges": ranges,
|
|
42
|
+
# No version information at all means the project as a whole is the
|
|
43
|
+
# finding: every release is covered.
|
|
44
|
+
"whole_package": not versions and not ranges,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def summarise(body: dict[str, Any], name: str | None = None) -> dict[str, Any]:
|
|
49
|
+
vulns = [v for v in (body.get("vulns") or []) if isinstance(v, dict)]
|
|
50
|
+
mal = [v for v in vulns if str(v.get("id", "")).startswith("MAL-")]
|
|
51
|
+
other_ids = sorted({str(v["id"]) for v in vulns if not str(v.get("id", "")).startswith("MAL-")})
|
|
52
|
+
records = [affected_spec(v, name) for v in mal]
|
|
53
|
+
return {
|
|
54
|
+
# True when a MAL- record exists for the *name*. Whether it covers the
|
|
55
|
+
# version in hand is a separate question, answered by `mal_records`.
|
|
56
|
+
"malicious": bool(mal),
|
|
57
|
+
"mal_ids": sorted({r["id"] for r in records}),
|
|
58
|
+
"mal_records": records,
|
|
59
|
+
"other_ids": other_ids,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def fetch(
|
|
64
|
+
client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
ecosystem = OSV_ECOSYSTEM.get(target.ecosystem)
|
|
67
|
+
if ecosystem is None:
|
|
68
|
+
return {
|
|
69
|
+
"malicious": False,
|
|
70
|
+
"mal_ids": [],
|
|
71
|
+
"mal_records": [],
|
|
72
|
+
"other_ids": [],
|
|
73
|
+
"queried": False,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Always query by name, never by version. Asking OSV to filter would hide
|
|
77
|
+
# which versions a record covers, and the check needs that to tell a
|
|
78
|
+
# compromised release from a clean one of the same project.
|
|
79
|
+
key = f"{ecosystem}:{target.name}"
|
|
80
|
+
if cache is not None:
|
|
81
|
+
cached = cache.get("osv", key)
|
|
82
|
+
if cached is not None:
|
|
83
|
+
return dict(cached)
|
|
84
|
+
|
|
85
|
+
payload: dict[str, Any] = {"package": {"name": target.name, "ecosystem": ecosystem}}
|
|
86
|
+
|
|
87
|
+
facts = summarise(await post_json(client, QUERY_URL, payload), name=target.name)
|
|
88
|
+
facts["queried"] = True
|
|
89
|
+
if cache is not None:
|
|
90
|
+
cache.set("osv", key, facts)
|
|
91
|
+
return facts
|
engine/clients/pypi.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""PyPI JSON API. Existence, release dates, maintainers, file hashes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from contracts.contracts import TargetRef
|
|
11
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
12
|
+
from packaging.tags import Tag, sys_tags
|
|
13
|
+
from packaging.utils import InvalidWheelFilename, parse_wheel_filename
|
|
14
|
+
from packaging.version import InvalidVersion, Version
|
|
15
|
+
|
|
16
|
+
from engine.cache import Cache
|
|
17
|
+
from engine.clients import get_json
|
|
18
|
+
|
|
19
|
+
BASE = "https://pypi.org/pypi"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def project_url(name: str) -> str:
|
|
23
|
+
return f"{BASE}/{name}/json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cache_key(name: str, python: tuple[int, int] | None = None) -> str:
|
|
27
|
+
major, minor = python or sys.version_info[:2]
|
|
28
|
+
return f"{name}:cp{major}{minor}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_upload_time(raw: str) -> datetime | None:
|
|
32
|
+
try:
|
|
33
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(UTC)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _python_ok(spec: object, python: str) -> bool:
|
|
39
|
+
if not spec:
|
|
40
|
+
return True
|
|
41
|
+
try:
|
|
42
|
+
return python in SpecifierSet(str(spec))
|
|
43
|
+
except InvalidSpecifier:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _usable_files(
|
|
48
|
+
files: list[dict[str, Any]], python: str, tags: list[Tag]
|
|
49
|
+
) -> list[dict[str, Any]]:
|
|
50
|
+
usable: list[dict[str, Any]] = []
|
|
51
|
+
for item in files:
|
|
52
|
+
if item.get("yanked"):
|
|
53
|
+
continue
|
|
54
|
+
if not _python_ok(item.get("requires_python"), python):
|
|
55
|
+
continue
|
|
56
|
+
usable.append(item)
|
|
57
|
+
if not usable:
|
|
58
|
+
return []
|
|
59
|
+
if _file_sha(usable, tags) is None:
|
|
60
|
+
return []
|
|
61
|
+
return usable
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _file_sha(files: list[dict[str, Any]], tags: list[Tag]) -> str | None:
|
|
65
|
+
wheels: list[tuple[frozenset[Tag], str]] = []
|
|
66
|
+
sdist: str | None = None
|
|
67
|
+
for item in files:
|
|
68
|
+
digest = (item.get("digests") or {}).get("sha256")
|
|
69
|
+
if not digest:
|
|
70
|
+
continue
|
|
71
|
+
name = str(item.get("filename") or "")
|
|
72
|
+
if name.endswith(".whl"):
|
|
73
|
+
try:
|
|
74
|
+
_, _, _, wheel_tags = parse_wheel_filename(name)
|
|
75
|
+
except InvalidWheelFilename:
|
|
76
|
+
continue
|
|
77
|
+
wheels.append((wheel_tags, str(digest)))
|
|
78
|
+
continue
|
|
79
|
+
if sdist is None:
|
|
80
|
+
sdist = str(digest)
|
|
81
|
+
for tag in tags:
|
|
82
|
+
for wheel_tags, digest in wheels:
|
|
83
|
+
if tag in wheel_tags:
|
|
84
|
+
return digest
|
|
85
|
+
return sdist
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _newest_installable(
|
|
89
|
+
releases: dict[str, list[dict[str, Any]]], python: str, tags: list[Tag]
|
|
90
|
+
) -> tuple[str | None, list[dict[str, Any]]]:
|
|
91
|
+
ranked: list[tuple[Version, str]] = []
|
|
92
|
+
for version in releases:
|
|
93
|
+
try:
|
|
94
|
+
ranked.append((Version(version), version))
|
|
95
|
+
except InvalidVersion:
|
|
96
|
+
continue
|
|
97
|
+
ranked.sort(reverse=True)
|
|
98
|
+
for _, version in ranked:
|
|
99
|
+
files = _usable_files(releases.get(version) or [], python, tags)
|
|
100
|
+
if files:
|
|
101
|
+
return version, files
|
|
102
|
+
return None, []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def summarise(
|
|
106
|
+
body: dict[str, Any] | None,
|
|
107
|
+
*,
|
|
108
|
+
python: tuple[int, int] | None = None,
|
|
109
|
+
tags: list[Tag] | None = None,
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Flatten the PyPI payload into the facts the checks need.
|
|
112
|
+
|
|
113
|
+
`latest_version` is what PyPI reports. `pin_version` is the newest release
|
|
114
|
+
this interpreter can actually install (requires_python + a matching wheel
|
|
115
|
+
or sdist). Unpinned checks pin `pin_version`, not a 3.12-only latest on 3.11.
|
|
116
|
+
"""
|
|
117
|
+
if body is None:
|
|
118
|
+
return {"exists": False}
|
|
119
|
+
|
|
120
|
+
info = body.get("info") or {}
|
|
121
|
+
releases: dict[str, list[dict[str, Any]]] = body.get("releases") or {}
|
|
122
|
+
if not releases and body.get("urls"):
|
|
123
|
+
# A release document (/pypi/{name}/{version}/json) has no `releases` map;
|
|
124
|
+
# its files are under `urls` and describe that one version. Without this
|
|
125
|
+
# the summary comes back with no dates and no hash, which reads as a
|
|
126
|
+
# package nobody ever uploaded.
|
|
127
|
+
releases = {str(info.get("version")): list(body["urls"])}
|
|
128
|
+
|
|
129
|
+
upload_times: list[datetime] = []
|
|
130
|
+
for files in releases.values():
|
|
131
|
+
for item in files:
|
|
132
|
+
parsed = _parse_upload_time(str(item.get("upload_time_iso_8601") or ""))
|
|
133
|
+
if parsed:
|
|
134
|
+
upload_times.append(parsed)
|
|
135
|
+
|
|
136
|
+
major, minor = python or sys.version_info[:2]
|
|
137
|
+
python_s = f"{major}.{minor}"
|
|
138
|
+
tag_list = tags if tags is not None else list(sys_tags())
|
|
139
|
+
|
|
140
|
+
latest_version = info.get("version")
|
|
141
|
+
latest_files = releases.get(str(latest_version), []) if latest_version else []
|
|
142
|
+
latest_upload = max(
|
|
143
|
+
(
|
|
144
|
+
t
|
|
145
|
+
for t in (
|
|
146
|
+
_parse_upload_time(str(f.get("upload_time_iso_8601") or "")) for f in latest_files
|
|
147
|
+
)
|
|
148
|
+
if t
|
|
149
|
+
),
|
|
150
|
+
default=None,
|
|
151
|
+
)
|
|
152
|
+
latest_sha = _file_sha(latest_files, tag_list) or next(
|
|
153
|
+
(
|
|
154
|
+
str((f.get("digests") or {}).get("sha256"))
|
|
155
|
+
for f in latest_files
|
|
156
|
+
if (f.get("digests") or {}).get("sha256")
|
|
157
|
+
),
|
|
158
|
+
None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
pin_version, pin_files = _newest_installable(releases, python_s, tag_list)
|
|
162
|
+
pin_sha = _file_sha(pin_files, tag_list) if pin_files else None
|
|
163
|
+
|
|
164
|
+
maintainers = sorted(
|
|
165
|
+
{
|
|
166
|
+
str(value)
|
|
167
|
+
for key in ("author", "maintainer", "author_email", "maintainer_email")
|
|
168
|
+
if (value := info.get(key))
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"exists": True,
|
|
174
|
+
"latest_version": latest_version,
|
|
175
|
+
"first_upload": min(upload_times).isoformat() if upload_times else None,
|
|
176
|
+
"latest_upload": latest_upload.isoformat() if latest_upload else None,
|
|
177
|
+
"maintainers": maintainers,
|
|
178
|
+
"latest_sha256": latest_sha,
|
|
179
|
+
"pin_version": pin_version,
|
|
180
|
+
"pin_sha256": pin_sha,
|
|
181
|
+
"release_count": len(releases),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def fetch(
|
|
186
|
+
client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
key = cache_key(target.name)
|
|
189
|
+
if cache is not None:
|
|
190
|
+
cached = cache.get("pypi", key)
|
|
191
|
+
if cached is not None:
|
|
192
|
+
return dict(cached)
|
|
193
|
+
body = await get_json(client, project_url(target.name), allow_404=True)
|
|
194
|
+
facts = summarise(body)
|
|
195
|
+
if cache is not None:
|
|
196
|
+
cache.set("pypi", key, facts)
|
|
197
|
+
return facts
|