phantom-scan 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.
phantom/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """phantom — detect divergence between a package's source and its published artifact.
2
+
3
+ Core entry point for library use::
4
+
5
+ from phantom.core import scan
6
+ from phantom.registry import build_default_registry
7
+
8
+ registry = build_default_registry()
9
+ result = scan("some-pkg", "1.2.3", registry.get("pypi"))
10
+ """
11
+
12
+ __version__ = "0.1.0"
phantom/cache.py ADDED
@@ -0,0 +1,63 @@
1
+ """Disk cache keyed by URL digest and the single HTTP helper. A cached
2
+ scan of the same ``pkg==version`` is deterministic and works offline."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import urllib.error
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+ from phantom.errors import FetchError, NotFoundError
12
+
13
+ USER_AGENT = "phantom/0.1 (+https://github.com/pipebreach/phantom)"
14
+
15
+
16
+ def default_cache_dir() -> Path:
17
+ return Path.home() / ".cache" / "phantom"
18
+
19
+
20
+ class DiskCache:
21
+ """Blob store keyed by the SHA-256 of an arbitrary string key."""
22
+
23
+ def __init__(self, root: Path) -> None:
24
+ self.root = root
25
+
26
+ def _path(self, key: str) -> Path:
27
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
28
+ return self.root / digest[:2] / digest
29
+
30
+ def get(self, key: str) -> bytes | None:
31
+ path = self._path(key)
32
+ if path.is_file():
33
+ return path.read_bytes()
34
+ return None
35
+
36
+ def put(self, key: str, data: bytes) -> None:
37
+ path = self._path(key)
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ tmp = path.with_suffix(".tmp")
40
+ tmp.write_bytes(data)
41
+ tmp.replace(path)
42
+
43
+
44
+ def http_get(url: str, cache: DiskCache | None = None, timeout: float = 60.0) -> bytes:
45
+ """GET a URL through the cache. Raises ``NotFoundError`` on 404,
46
+ ``FetchError`` on any other failure."""
47
+ if cache is not None:
48
+ cached = cache.get(url)
49
+ if cached is not None:
50
+ return cached
51
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
52
+ try:
53
+ with urllib.request.urlopen(request, timeout=timeout) as response:
54
+ data = response.read()
55
+ except urllib.error.HTTPError as exc:
56
+ if exc.code == 404:
57
+ raise NotFoundError(f"404 Not Found: {url}") from exc
58
+ raise FetchError(f"HTTP {exc.code} fetching {url}") from exc
59
+ except urllib.error.URLError as exc:
60
+ raise FetchError(f"network error fetching {url}: {exc.reason}") from exc
61
+ if cache is not None:
62
+ cache.put(url, data)
63
+ return data
phantom/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ """Thin CLI layer over ``phantom.core.scan``. Exit codes: 0 clean, 1 findings
2
+ of high/critical severity, 2 execution error, 3 out of scope."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from phantom import __version__, core
11
+ from phantom.cache import DiskCache, default_cache_dir
12
+ from phantom.errors import OutOfScopeError, PhantomError
13
+ from phantom.registry import Registry, build_default_registry
14
+ from phantom.report import json_report, sarif_report, table_report
15
+
16
+ EXIT_OK = 0
17
+ EXIT_FINDINGS = 1
18
+ EXIT_ERROR = 2
19
+ EXIT_OUT_OF_SCOPE = 3
20
+
21
+ _EXIT_CODES_HELP = """\
22
+ exit codes:
23
+ 0 no findings (or only medium/low severity)
24
+ 1 at least one high/critical finding
25
+ 2 execution error
26
+ 3 package out of scope (no pure-Python wheel available)
27
+ """
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="phantom",
33
+ description=(
34
+ "Detect divergence between a package's declared source and its "
35
+ "published artifact."
36
+ ),
37
+ epilog=_EXIT_CODES_HELP,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ parser.add_argument("--version", action="version", version=f"phantom {__version__}")
41
+ subparsers = parser.add_subparsers(dest="command", required=True)
42
+
43
+ scan = subparsers.add_parser(
44
+ "scan",
45
+ help="scan a single package version (e.g. phantom scan requests==2.31.0)",
46
+ epilog=_EXIT_CODES_HELP,
47
+ formatter_class=argparse.RawDescriptionHelpFormatter,
48
+ )
49
+ scan.add_argument("spec", help="package spec in the form <pkg>==<version>")
50
+ scan.add_argument(
51
+ "--ecosystem",
52
+ default="pypi",
53
+ help="package ecosystem (default: pypi)",
54
+ )
55
+ output = scan.add_mutually_exclusive_group()
56
+ output.add_argument(
57
+ "--json", action="store_true", help="emit the versioned JSON schema"
58
+ )
59
+ output.add_argument(
60
+ "--sarif", action="store_true", help="emit SARIF 2.1.0 for code scanning"
61
+ )
62
+ scan.add_argument(
63
+ "--cache-dir",
64
+ type=Path,
65
+ default=None,
66
+ help=f"download cache directory (default: {default_cache_dir()})",
67
+ )
68
+
69
+ audit = subparsers.add_parser(
70
+ "audit",
71
+ help="scan every package in a lockfile (requirements.txt / poetry.lock) [M2]",
72
+ )
73
+ audit.add_argument("lockfile", type=Path, help="path to the lockfile")
74
+
75
+ return parser
76
+
77
+
78
+ def run(argv: list[str] | None = None, registry: Registry | None = None) -> int:
79
+ """Parse arguments and execute; ``registry`` is injectable for tests."""
80
+ args = build_parser().parse_args(argv)
81
+
82
+ if args.command == "audit":
83
+ print(
84
+ "phantom audit is not implemented yet (planned for M2).",
85
+ file=sys.stderr,
86
+ )
87
+ return EXIT_ERROR
88
+
89
+ try:
90
+ package, version = _parse_spec(args.spec)
91
+ except ValueError as exc:
92
+ print(f"error: {exc}", file=sys.stderr)
93
+ return EXIT_ERROR
94
+
95
+ if registry is None:
96
+ cache = DiskCache(args.cache_dir or default_cache_dir())
97
+ registry = build_default_registry(cache)
98
+
99
+ try:
100
+ ecosystem = registry.get(args.ecosystem)
101
+ result = core.scan(package, version, ecosystem)
102
+ except OutOfScopeError as exc:
103
+ print(f"out of scope: {exc}", file=sys.stderr)
104
+ return EXIT_OUT_OF_SCOPE
105
+ except PhantomError as exc:
106
+ print(f"error: {exc}", file=sys.stderr)
107
+ return EXIT_ERROR
108
+
109
+ if args.json:
110
+ print(json_report.render(result))
111
+ elif args.sarif:
112
+ print(sarif_report.render(result))
113
+ else:
114
+ print(table_report.render(result))
115
+
116
+ return core.exit_code_for(result)
117
+
118
+
119
+ def _parse_spec(spec: str) -> tuple[str, str]:
120
+ package, sep, version = spec.partition("==")
121
+ if not sep or not package or not version:
122
+ raise ValueError(
123
+ f"invalid spec {spec!r}: expected exact pin <pkg>==<version>"
124
+ )
125
+ return package.strip(), version.strip()
126
+
127
+
128
+ def main() -> None:
129
+ sys.exit(run())
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
phantom/core.py ADDED
@@ -0,0 +1,81 @@
1
+ """Orchestrator and library entry point: drives the ``Ecosystem`` interfaces
2
+ only, holds no state."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from phantom import differ
7
+ from phantom.ecosystems.base import Ecosystem
8
+ from phantom.models import (
9
+ Confidence,
10
+ Finding,
11
+ FindingType,
12
+ NoSource,
13
+ ScanResult,
14
+ Severity,
15
+ SourceStatus,
16
+ )
17
+
18
+
19
+ def scan(package: str, version: str, ecosystem: Ecosystem) -> ScanResult:
20
+ """Scan one package version for source/artifact divergence.
21
+
22
+ Propagates fetcher errors; unresolvable source is a finding, not an error.
23
+ """
24
+ artifact = ecosystem.fetcher.fetch_artifact(package, version)
25
+ source = ecosystem.source_resolver.resolve_source(
26
+ package, version, artifact.metadata
27
+ )
28
+
29
+ if isinstance(source, NoSource):
30
+ return _no_source_result(package, version, ecosystem.name, source)
31
+
32
+ outcome = differ.diff(artifact, source, ecosystem.normalizers)
33
+ return ScanResult(
34
+ package=package,
35
+ version=version,
36
+ ecosystem=ecosystem.name,
37
+ source_status=SourceStatus.RESOLVED,
38
+ source_repo=source.repo_url,
39
+ source_ref=source.ref,
40
+ findings=outcome.findings,
41
+ files_scanned=outcome.files_scanned,
42
+ )
43
+
44
+
45
+ def exit_code_for(result: ScanResult) -> int:
46
+ """Return 1 if any finding is high or critical, else 0."""
47
+ if any(f.severity in (Severity.HIGH, Severity.CRITICAL) for f in result.findings):
48
+ return 1
49
+ return 0
50
+
51
+
52
+ def _no_source_result(
53
+ package: str, version: str, ecosystem: str, source: NoSource
54
+ ) -> ScanResult:
55
+ if source.finding_type == FindingType.NO_SOURCE_DECLARED:
56
+ # Unverifiable by construction: a risk in itself.
57
+ status = SourceStatus.NO_SOURCE_DECLARED
58
+ severity = Severity.HIGH
59
+ confidence = Confidence.HIGH
60
+ else:
61
+ # A repo exists but no tag convention matched; flag softly.
62
+ status = SourceStatus.REF_NOT_FOUND
63
+ severity = Severity.MEDIUM
64
+ confidence = Confidence.MEDIUM
65
+ finding = Finding(
66
+ type=source.finding_type,
67
+ path=None,
68
+ severity=severity,
69
+ confidence=confidence,
70
+ reason=source.detail,
71
+ )
72
+ return ScanResult(
73
+ package=package,
74
+ version=version,
75
+ ecosystem=ecosystem,
76
+ source_status=status,
77
+ source_repo=source.repo_url,
78
+ source_ref=None,
79
+ findings=[finding],
80
+ files_scanned=0,
81
+ )
phantom/differ.py ADDED
@@ -0,0 +1,129 @@
1
+ """Compares artifact files against a source hash index.
2
+
3
+ Content-based and path-agnostic: a wheel file matches if its normalized hash
4
+ exists anywhere in the source tree, so build-time moves and renames do not
5
+ cause false positives.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from phantom import risk
13
+ from phantom.models import (
14
+ Artifact,
15
+ Confidence,
16
+ FileEntry,
17
+ Finding,
18
+ FindingType,
19
+ Severity,
20
+ SourceTree,
21
+ )
22
+ from phantom.normalizers.base import Normalizer
23
+
24
+ # Commonly generated at build time (setuptools-scm, hatch-vcs); a phantom
25
+ # hit on these gets low confidence.
26
+ _LIKELY_GENERATED_BASENAMES = {"_version.py", "version.py", "_version_meta.py"}
27
+
28
+
29
+ @dataclass
30
+ class DiffOutcome:
31
+ findings: list[Finding]
32
+ files_scanned: int
33
+
34
+
35
+ def diff(
36
+ artifact: Artifact, source: SourceTree, normalizers: list[Normalizer]
37
+ ) -> DiffOutcome:
38
+ source_hashes = _build_index(source.files, normalizers)
39
+ findings: list[Finding] = []
40
+ files_scanned = 0
41
+
42
+ for file in artifact.files:
43
+ if _is_packaging_metadata(file.path):
44
+ continue
45
+ if file.path.endswith(".pth"):
46
+ files_scanned += 1
47
+ findings.append(_pth_finding(file))
48
+ continue
49
+ normalizer = _normalizer_for(file, normalizers)
50
+ if normalizer is None:
51
+ continue
52
+ files_scanned += 1
53
+ if normalizer.normalized_hash(file) in source_hashes:
54
+ continue
55
+ findings.append(_phantom_finding(file, source))
56
+
57
+ findings.sort(key=lambda f: (-f.severity.rank, f.path or ""))
58
+ return DiffOutcome(findings=findings, files_scanned=files_scanned)
59
+
60
+
61
+ def _build_index(files: list[FileEntry], normalizers: list[Normalizer]) -> set[str]:
62
+ index: set[str] = set()
63
+ for file in files:
64
+ normalizer = _normalizer_for(file, normalizers)
65
+ if normalizer is not None:
66
+ index.add(normalizer.normalized_hash(file))
67
+ return index
68
+
69
+
70
+ def _normalizer_for(
71
+ file: FileEntry, normalizers: list[Normalizer]
72
+ ) -> Normalizer | None:
73
+ for normalizer in normalizers:
74
+ if normalizer.applies_to(file):
75
+ return normalizer
76
+ return None
77
+
78
+
79
+ def _is_packaging_metadata(path: str) -> bool:
80
+ first = path.split("/", 1)[0]
81
+ return first.endswith(".dist-info") or first.endswith(".data")
82
+
83
+
84
+ def _pth_finding(file: FileEntry) -> Finding:
85
+ # .pth import lines execute at every interpreter startup.
86
+ has_import = any(
87
+ line.lstrip().startswith("import ")
88
+ for line in file.data.decode("utf-8", errors="replace").splitlines()
89
+ )
90
+ return Finding(
91
+ type=FindingType.SUSPICIOUS_PTH,
92
+ path=file.path,
93
+ severity=Severity.CRITICAL if has_import else Severity.HIGH,
94
+ confidence=Confidence.HIGH,
95
+ reason=(
96
+ ".pth file shipped inside a wheel; "
97
+ + (
98
+ "it contains import lines that execute code at every "
99
+ "interpreter startup"
100
+ if has_import
101
+ else "legitimate wheels almost never include .pth files"
102
+ )
103
+ ),
104
+ execution_vectors=["startup:.pth-import"] if has_import else [],
105
+ )
106
+
107
+
108
+ def _phantom_finding(file: FileEntry, source: SourceTree) -> Finding:
109
+ assessment = risk.assess(file)
110
+ basename = file.path.rsplit("/", 1)[-1]
111
+ confidence = Confidence.HIGH
112
+ reason = (
113
+ f"content of {file.path} has no normalized-hash match anywhere in "
114
+ f"{source.repo_url}@{source.ref}; {assessment.detail}"
115
+ )
116
+ if basename in _LIKELY_GENERATED_BASENAMES:
117
+ confidence = Confidence.LOW
118
+ reason += (
119
+ "; note: this filename is commonly generated at build time "
120
+ "(e.g. setuptools-scm), so this may be legitimate generated code"
121
+ )
122
+ return Finding(
123
+ type=FindingType.PHANTOM_FILE,
124
+ path=file.path,
125
+ severity=assessment.severity,
126
+ confidence=confidence,
127
+ reason=reason,
128
+ execution_vectors=assessment.vectors,
129
+ )
@@ -0,0 +1,3 @@
1
+ from phantom.ecosystems.base import Ecosystem, Fetcher, SourceResolver
2
+
3
+ __all__ = ["Ecosystem", "Fetcher", "SourceResolver"]
@@ -0,0 +1,54 @@
1
+ """Ecosystem plugin contracts. ``phantom.core.scan()`` only speaks these
2
+ interfaces; adding an ecosystem never touches the core or the differ."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+
8
+ from phantom.models import Artifact, NoSource, SourceTree
9
+ from phantom.normalizers.base import Normalizer
10
+
11
+
12
+ class Fetcher(ABC):
13
+ """Obtains the published artifact for a package version."""
14
+
15
+ @abstractmethod
16
+ def fetch_artifact(self, pkg: str, version: str) -> Artifact:
17
+ """Download and unpack the artifact.
18
+
19
+ Raises ``OutOfScopeError`` if the release cannot be analyzed,
20
+ ``NotFoundError`` if it does not exist, ``FetchError`` on network
21
+ failure.
22
+ """
23
+
24
+
25
+ class SourceResolver(ABC):
26
+ """Locates the source tree that should match the published version."""
27
+
28
+ @abstractmethod
29
+ def resolve_source(
30
+ self, pkg: str, version: str, metadata: dict
31
+ ) -> SourceTree | NoSource:
32
+ """Resolve repo + ref from registry metadata and fetch the tree.
33
+
34
+ Returns ``NoSource`` instead of raising: unresolvable source is a
35
+ finding, not an error.
36
+ """
37
+
38
+
39
+ class Ecosystem(ABC):
40
+ """Bundle of everything needed to scan one packaging ecosystem."""
41
+
42
+ name: str
43
+
44
+ @property
45
+ @abstractmethod
46
+ def fetcher(self) -> Fetcher: ...
47
+
48
+ @property
49
+ @abstractmethod
50
+ def source_resolver(self) -> SourceResolver: ...
51
+
52
+ @property
53
+ @abstractmethod
54
+ def normalizers(self) -> list[Normalizer]: ...
@@ -0,0 +1,9 @@
1
+ """npm ecosystem — stub, planned for M2 (FR-10).
2
+
3
+ Will implement a ``Fetcher`` for registry tarballs (``.tgz``), a
4
+ ``SourceResolver`` for the ``repository`` field of ``package.json``, and
5
+ normalizers for JS minification/transpilation (M3). Registering it in
6
+ ``phantom.registry`` is all the core needs.
7
+ """
8
+
9
+ from __future__ import annotations
@@ -0,0 +1,203 @@
1
+ """PyPI ecosystem: wheels from the JSON API, sources from GitHub tag
2
+ tarballs. Only pure-Python wheels (``*-none-any.whl``) are in scope."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import json
8
+ import re
9
+ import tarfile
10
+ import zipfile
11
+
12
+ from phantom.cache import DiskCache, http_get
13
+ from phantom.ecosystems.base import Ecosystem, Fetcher, SourceResolver
14
+ from phantom.errors import FetchError, NotFoundError, OutOfScopeError
15
+ from phantom.models import Artifact, FileEntry, FindingType, NoSource, SourceTree
16
+ from phantom.normalizers.base import Normalizer
17
+ from phantom.normalizers.python_ast import PythonASTNormalizer
18
+
19
+ PYPI_JSON_URL = "https://pypi.org/pypi/{pkg}/{version}/json"
20
+ # codeload serves public tag tarballs without the API rate limit.
21
+ GITHUB_TARBALL_URL = "https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"
22
+
23
+ # Tag conventions tried in order.
24
+ TAG_PATTERNS = ("v{version}", "{version}", "release-{version}")
25
+
26
+ # project_urls keys checked in priority order.
27
+ _SOURCE_URL_KEYS = ("source", "source code", "repository", "code", "github", "homepage")
28
+
29
+ _GITHUB_RE = re.compile(
30
+ r"https?://(?:www\.)?github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)"
31
+ )
32
+
33
+
34
+ def _parse_github_repo(url: str) -> tuple[str, str] | None:
35
+ match = _GITHUB_RE.match(url.strip())
36
+ if not match:
37
+ return None
38
+ owner = match.group("owner")
39
+ repo = match.group("repo").removesuffix(".git")
40
+ return owner, repo
41
+
42
+
43
+ class PyPIFetcher(Fetcher):
44
+ def __init__(self, cache: DiskCache | None = None) -> None:
45
+ self.cache = cache
46
+
47
+ def fetch_artifact(self, pkg: str, version: str) -> Artifact:
48
+ url = PYPI_JSON_URL.format(pkg=pkg, version=version)
49
+ try:
50
+ metadata = json.loads(http_get(url, self.cache))
51
+ except NotFoundError as exc:
52
+ raise NotFoundError(f"{pkg}=={version} not found on PyPI") from exc
53
+
54
+ releases = metadata.get("urls", [])
55
+ wheel = self._pick_pure_wheel(releases)
56
+ if wheel is None:
57
+ raise OutOfScopeError(self._out_of_scope_reason(pkg, version, releases))
58
+
59
+ data = http_get(wheel["url"], self.cache)
60
+ files = self._unpack_wheel(data)
61
+ return Artifact(
62
+ package=pkg,
63
+ version=version,
64
+ filename=wheel["filename"],
65
+ files=files,
66
+ metadata=metadata.get("info", {}),
67
+ )
68
+
69
+ @staticmethod
70
+ def _pick_pure_wheel(releases: list[dict]) -> dict | None:
71
+ for release in releases:
72
+ if release.get("packagetype") == "bdist_wheel" and release.get(
73
+ "filename", ""
74
+ ).endswith("-none-any.whl"):
75
+ return release
76
+ return None
77
+
78
+ @staticmethod
79
+ def _out_of_scope_reason(pkg: str, version: str, releases: list[dict]) -> str:
80
+ kinds = sorted({r.get("packagetype", "?") for r in releases})
81
+ if "bdist_wheel" in kinds:
82
+ detail = "only platform-specific (compiled) wheels are published"
83
+ elif "sdist" in kinds:
84
+ detail = "only an sdist is published"
85
+ else:
86
+ detail = "no distribution files found"
87
+ return (
88
+ f"{pkg}=={version}: {detail}. M1 only supports pure-Python wheels "
89
+ f"(*-none-any.whl)."
90
+ )
91
+
92
+ @staticmethod
93
+ def _unpack_wheel(data: bytes) -> list[FileEntry]:
94
+ try:
95
+ archive = zipfile.ZipFile(io.BytesIO(data))
96
+ except zipfile.BadZipFile as exc:
97
+ raise FetchError(f"downloaded wheel is not a valid zip: {exc}") from exc
98
+ entries = []
99
+ for info in archive.infolist():
100
+ if info.is_dir():
101
+ continue
102
+ entries.append(FileEntry(path=info.filename, data=archive.read(info)))
103
+ return entries
104
+
105
+
106
+ class PyPISourceResolver(SourceResolver):
107
+ def __init__(self, cache: DiskCache | None = None) -> None:
108
+ self.cache = cache
109
+
110
+ def resolve_source(
111
+ self, pkg: str, version: str, metadata: dict
112
+ ) -> SourceTree | NoSource:
113
+ repo = self._find_github_repo(metadata)
114
+ if repo is None:
115
+ return NoSource(
116
+ finding_type=FindingType.NO_SOURCE_DECLARED,
117
+ detail=(
118
+ f"{pkg} declares no GitHub source repository in its PyPI "
119
+ f"metadata (project_urls/home_page). The published artifact "
120
+ f"cannot be verified against any source."
121
+ ),
122
+ )
123
+ owner, name = repo
124
+ repo_url = f"https://github.com/{owner}/{name}"
125
+ tried = []
126
+ for pattern in TAG_PATTERNS:
127
+ tag = pattern.format(version=version)
128
+ tried.append(tag)
129
+ try:
130
+ data = http_get(
131
+ GITHUB_TARBALL_URL.format(owner=owner, repo=name, tag=tag),
132
+ self.cache,
133
+ )
134
+ except NotFoundError:
135
+ continue
136
+ return SourceTree(repo_url=repo_url, ref=tag, files=_untar(data))
137
+ return NoSource(
138
+ finding_type=FindingType.SOURCE_REF_NOT_FOUND,
139
+ detail=(
140
+ f"no tag matching version {version} found in {repo_url} "
141
+ f"(tried: {', '.join(tried)})"
142
+ ),
143
+ repo_url=repo_url,
144
+ )
145
+
146
+ @staticmethod
147
+ def _find_github_repo(metadata: dict) -> tuple[str, str] | None:
148
+ project_urls = {
149
+ key.lower(): value
150
+ for key, value in (metadata.get("project_urls") or {}).items()
151
+ if value
152
+ }
153
+ candidates = [
154
+ project_urls[key] for key in _SOURCE_URL_KEYS if key in project_urls
155
+ ]
156
+ candidates += [v for v in project_urls.values() if v not in candidates]
157
+ if metadata.get("home_page"):
158
+ candidates.append(metadata["home_page"])
159
+ for url in candidates:
160
+ repo = _parse_github_repo(url)
161
+ if repo is not None:
162
+ return repo
163
+ return None
164
+
165
+
166
+ def _untar(data: bytes) -> list[FileEntry]:
167
+ """Extract a tarball in memory, stripping GitHub's top-level directory."""
168
+ try:
169
+ archive = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
170
+ except tarfile.TarError as exc:
171
+ raise FetchError(f"downloaded source tarball is invalid: {exc}") from exc
172
+ entries = []
173
+ with archive:
174
+ for member in archive.getmembers():
175
+ if not member.isfile():
176
+ continue
177
+ path = member.name.split("/", 1)[1] if "/" in member.name else member.name
178
+ handle = archive.extractfile(member)
179
+ if handle is None:
180
+ continue
181
+ entries.append(FileEntry(path=path, data=handle.read()))
182
+ return entries
183
+
184
+
185
+ class PyPIEcosystem(Ecosystem):
186
+ name = "pypi"
187
+
188
+ def __init__(self, cache: DiskCache | None = None) -> None:
189
+ self._fetcher = PyPIFetcher(cache)
190
+ self._source_resolver = PyPISourceResolver(cache)
191
+ self._normalizers: list[Normalizer] = [PythonASTNormalizer()]
192
+
193
+ @property
194
+ def fetcher(self) -> Fetcher:
195
+ return self._fetcher
196
+
197
+ @property
198
+ def source_resolver(self) -> SourceResolver:
199
+ return self._source_resolver
200
+
201
+ @property
202
+ def normalizers(self) -> list[Normalizer]:
203
+ return self._normalizers