codeanalyzer-python 1.2.0__py3-none-any.whl → 1.3.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.
@@ -0,0 +1,237 @@
1
+ """PyDependency / PyImportBinding construction from discovered artifacts.
2
+
3
+ Deterministic by default: reads only repo files. ``resolve_installed`` adds
4
+ filesystem reads of ``<venv>/**/site-packages/*.dist-info`` (never runs an
5
+ interpreter), tagged ``prov: installed-metadata``.
6
+
7
+ ``-r``/``-c`` refs in a requirements-format manifest are chased one level
8
+ only, by design: a chased target's own refs are not followed further.
9
+
10
+ ``unresolved_imports`` is byte-identical run-to-run only within one Python
11
+ minor version: it is filtered against ``sys.stdlib_module_names``, and that
12
+ set's membership varies across minors (a module added to or removed from the
13
+ stdlib)."""
14
+
15
+ import posixpath
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional, Tuple
20
+
21
+ from codeanalyzer.artifacts.parsers import (
22
+ RawDep, _kind_for_requirements, normalize_name, parse_lock_pins, parse_manifest,
23
+ parse_requirement_refs,
24
+ )
25
+ from codeanalyzer.schema.py_schema import (
26
+ PyArtifact, PyDependency, PyImportBinding, PyModule,
27
+ )
28
+
29
+ _LOCK_BASENAMES = ("poetry.lock", "uv.lock", "Pipfile.lock")
30
+
31
+ # Small, non-exhaustive alias table for the worst offenders; everything else
32
+ # rides the same-name rule or --resolve-installed. prov: heuristic. Identity
33
+ # entries (key == value) do not belong here -- the same-name rule below
34
+ # already covers them, and a redundant identity entry only mints a spurious
35
+ # "heuristic" prov on what is actually a plain same-name match.
36
+ _KNOWN_IMPORT_ALIASES: Dict[str, str] = {
37
+ "pyyaml": "yaml", "beautifulsoup4": "bs4", "pillow": "PIL",
38
+ "scikit-learn": "sklearn", "opencv-python": "cv2", "python-dateutil": "dateutil",
39
+ "msgpack-python": "msgpack", "protobuf": "google.protobuf", "attrs": "attr",
40
+ }
41
+
42
+
43
+ def _stdlib_names() -> set:
44
+ return set(getattr(sys, "stdlib_module_names", ())) | {"__future__"}
45
+
46
+
47
+ def _installed_top_levels(venv_dir: Optional[Path]) -> Dict[str, List[str]]:
48
+ """{normalized dist name: [top-level import names]} from *.dist-info files."""
49
+ out: Dict[str, List[str]] = {}
50
+ if venv_dir is None or not venv_dir.exists():
51
+ return out
52
+ for di in sorted(venv_dir.glob("**/site-packages/*.dist-info")):
53
+ name = None
54
+ meta = di / "METADATA"
55
+ if meta.exists():
56
+ m = re.search(r"^Name:\s*(.+)$", meta.read_text(errors="replace"), re.M)
57
+ if m:
58
+ name = normalize_name(m.group(1).strip())
59
+ if name is None:
60
+ continue
61
+ tl = di / "top_level.txt"
62
+ if tl.exists():
63
+ out[name] = [l.strip() for l in tl.read_text().splitlines() if l.strip()]
64
+ return out
65
+
66
+
67
+ def _is_requirements_format(path: str) -> bool:
68
+ base = path.rsplit("/", 1)[-1]
69
+ return base.startswith("requirements") and base.endswith(".txt")
70
+
71
+
72
+ def _resolve_ref(manifest_path: str, ref: str) -> Optional[str]:
73
+ """POSIX-join a ``-r``/``-c`` ref against its manifest's directory,
74
+ normalized and repo-relative. ``None`` if it would escape ``project_dir``."""
75
+ manifest_dir = manifest_path.rsplit("/", 1)[0] if "/" in manifest_path else ""
76
+ joined = posixpath.normpath(posixpath.join(manifest_dir, ref) if manifest_dir else ref)
77
+ if joined == ".." or joined.startswith("../") or posixpath.isabs(joined):
78
+ return None
79
+ return joined
80
+
81
+
82
+ def _full_text(project_dir: Path, path: str, art: PyArtifact) -> str:
83
+ """Manifest/lock extraction must never depend on the stored ``source`` --
84
+ that's capped by ``text_max_bytes`` and emptied by ``capture_text=False``
85
+ (both payload-size controls on the JSON/Neo4j payload, not extraction
86
+ controls). Read the real file fresh instead; fall back to ``art.source``
87
+ only if it is gone (e.g. a synthetic artifact in a unit test, or the file
88
+ vanished mid-run).
89
+
90
+ Mirrored (not imported -- this name is module-private) by
91
+ ``core._artifact_full_text`` for the same reason on config-key
92
+ extraction (#152); keep the two in sync if this logic changes."""
93
+ try:
94
+ return (project_dir / path).read_bytes().decode("utf-8")
95
+ except (OSError, UnicodeDecodeError):
96
+ return art.source
97
+
98
+
99
+ def build_dependency_view(
100
+ artifacts: Dict[str, PyArtifact],
101
+ modules: Dict[str, PyModule],
102
+ project_dir: Path,
103
+ venv_dir: Optional[Path],
104
+ resolve_installed: bool,
105
+ ) -> Tuple[List[PyDependency], List[PyImportBinding]]:
106
+ deps: List[PyDependency] = []
107
+
108
+ def _emit(raw: List[RawDep], declared_in: str, kind_override: Optional[str] = None) -> None:
109
+ for r in raw:
110
+ deps.append(PyDependency(
111
+ name=r.name, ecosystem="pypi", spec=r.spec,
112
+ kind=kind_override if kind_override is not None else r.kind,
113
+ extras=sorted(r.extras), declared_in=declared_in, prov=["declared"],
114
+ ))
115
+
116
+ # 1. Declared records from every dependency-manifest artifact (non-lock).
117
+ for path in sorted(artifacts):
118
+ art = artifacts[path]
119
+ if "dependency-manifest" not in art.roles:
120
+ continue
121
+ if path.rsplit("/", 1)[-1] in _LOCK_BASENAMES:
122
+ continue
123
+ text = _full_text(project_dir, path, art)
124
+ raw, partial = parse_manifest(path, text)
125
+ art.extraction = "partial" if partial else "full"
126
+ _emit(raw, art.id)
127
+
128
+ # 1b. -r/-c refs: a target that is itself a dependency-manifest is
129
+ # parsed on its own above; a target with no RULES match for that role
130
+ # (e.g. base.txt -- never-drop inventory still captures it, just not
131
+ # as a manifest) is chased here and attributed to the referring
132
+ # artifact. Gate on the role, not mere presence in `artifacts`: since
133
+ # #157 every file is discovered, so presence alone no longer implies
134
+ # "already parsed as a manifest above".
135
+ if not _is_requirements_format(path):
136
+ continue
137
+ for ref in parse_requirement_refs(text):
138
+ resolved = _resolve_ref(path, ref)
139
+ if resolved is None:
140
+ continue
141
+ target_art = artifacts.get(resolved)
142
+ if target_art is not None and "dependency-manifest" in target_art.roles:
143
+ continue
144
+ target = project_dir / resolved
145
+ if not target.is_file():
146
+ continue
147
+ try:
148
+ ref_text = target.read_bytes().decode("utf-8")
149
+ except UnicodeDecodeError:
150
+ continue
151
+ # Force requirements-format dispatch (chased targets may not be
152
+ # named requirements*.txt), but recompute kind from the real
153
+ # basename so e.g. `-r dev.txt` still yields kind="dev".
154
+ raw_ref, _ = parse_manifest("requirements.txt", ref_text)
155
+ real_kind = _kind_for_requirements(resolved.rsplit("/", 1)[-1])
156
+ _emit(raw_ref, art.id, kind_override=real_kind)
157
+
158
+ # 2. Lock backfill (locked_version + prov "lockfile"). A pin with no
159
+ # manifest declaration is a *transitive* dependency: emitted with
160
+ # direct=False, attributed to the lock artifact (#152 reconciliation).
161
+ pins: Dict[str, str] = {}
162
+ pin_lock_artifact: Dict[str, str] = {}
163
+ for path in sorted(artifacts):
164
+ if path.rsplit("/", 1)[-1] in _LOCK_BASENAMES:
165
+ lock_text = _full_text(project_dir, path, artifacts[path])
166
+ lock_pins = parse_lock_pins(path, lock_text)
167
+ pins.update(lock_pins)
168
+ for name in lock_pins:
169
+ pin_lock_artifact[name] = artifacts[path].id
170
+ # A lock with real content that yields zero pins failed to parse
171
+ # (corrupt/unrecognized shape) -- don't claim "full" extraction
172
+ # for nothing extracted. An empty/whitespace-only lock is not a
173
+ # failure (nothing to extract), so it still counts as "full".
174
+ artifacts[path].extraction = (
175
+ "full" if lock_pins or not lock_text.strip() else "partial"
176
+ )
177
+ for d in deps:
178
+ if d.name in pins:
179
+ d.locked_version = pins[d.name]
180
+ d.prov = sorted(set(d.prov) | {"lockfile"})
181
+ declared_names = {d.name for d in deps}
182
+ for name in sorted(set(pins) - declared_names):
183
+ deps.append(PyDependency(
184
+ name=name, ecosystem="pypi", kind="runtime", declared_in=pin_lock_artifact[name],
185
+ direct=False, locked_version=pins[name], prov=["lockfile"],
186
+ ))
187
+
188
+ # 3. Import universe from the symbol table (top-level segments only).
189
+ # `module_name` is `py_file.stem` -- the leaf filename only (e.g. "api"
190
+ # for "odoo/api.py"), never the package path -- so it alone misses the
191
+ # top-level package name itself. Derive that from the symbol-table KEYS
192
+ # (repo-relative POSIX paths) too: first path segment when nested, else
193
+ # the root file's own stem. Keep the module_name-derived stems as well
194
+ # (harmless -- still excludes leaf-name imports the key pass can't see).
195
+ local = {m.module_name.split(".")[0] for m in modules.values() if m.module_name}
196
+ local |= {
197
+ key.split("/", 1)[0] if "/" in key else Path(key).stem for key in modules
198
+ }
199
+ stdlib = _stdlib_names()
200
+ imported: set = set()
201
+ for m in modules.values():
202
+ for imp in m.imports or []:
203
+ top = (imp.module or imp.name or "").split(".")[0]
204
+ if top and top not in stdlib and top not in local:
205
+ imported.add(top)
206
+
207
+ # 4. provides_imports: same-name rule, alias table, optional installed metadata.
208
+ installed = _installed_top_levels(venv_dir) if resolve_installed else {}
209
+ for d in deps:
210
+ provides: List[str] = []
211
+ same = d.name.replace("-", "_")
212
+ for candidate in {d.name, same}:
213
+ if candidate in imported:
214
+ provides.append(candidate)
215
+ alias = _KNOWN_IMPORT_ALIASES.get(d.name)
216
+ if alias and alias.split(".")[0] in imported:
217
+ provides.append(alias)
218
+ d.prov = sorted(set(d.prov) | {"heuristic"})
219
+ if d.name in installed:
220
+ for top in installed[d.name]:
221
+ if top in imported and top not in provides:
222
+ provides.append(top)
223
+ d.prov = sorted(set(d.prov) | {"installed-metadata"})
224
+ d.provides_imports = sorted(set(provides))
225
+
226
+ # 5. Unresolved: imported, not stdlib/local, not provided by any dependency.
227
+ # Top-level segment only: `imported` (step 3) is already top-level-only, but
228
+ # a dotted alias (e.g. protobuf -> "google.protobuf") puts the FULL dotted
229
+ # path into provides_imports, so comparing it against `imported` verbatim
230
+ # never matches and "google" falsely resurfaces as unresolved even though
231
+ # protobuf declares it.
232
+ provided = {p.split(".")[0] for d in deps for p in d.provides_imports}
233
+ unresolved = [
234
+ PyImportBinding(module=m) for m in sorted(imported - provided)
235
+ ]
236
+ deps.sort(key=lambda d: (d.name, d.declared_in))
237
+ return deps, unresolved
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import hashlib
5
+ from pathlib import Path
6
+ from typing import Dict, List, Tuple
7
+
8
+ from codeanalyzer.schema.ids import artifact_id
9
+ from codeanalyzer.schema.py_schema import PyArtifact
10
+
11
+ # (glob pattern against the repo-relative POSIX path, format, roles).
12
+ # First match wins; patterns are checked in order.
13
+ RULES: List[Tuple[str, str, List[str]]] = [
14
+ ("requirements*.txt", "requirements", ["dependency-manifest"]),
15
+ ("pyproject.toml", "toml", ["dependency-manifest", "tool-config"]),
16
+ ("setup.py", "text", ["dependency-manifest"]),
17
+ ("setup.cfg", "ini", ["dependency-manifest", "tool-config"]),
18
+ ("Pipfile", "toml", ["dependency-manifest"]),
19
+ ("Pipfile.lock", "json", ["dependency-manifest"]),
20
+ ("poetry.lock", "toml", ["dependency-manifest"]),
21
+ ("uv.lock", "toml", ["dependency-manifest"]),
22
+ ("environment.yml", "yaml", ["dependency-manifest"]),
23
+ ("environment.yaml", "yaml", ["dependency-manifest"]),
24
+ ("Dockerfile", "dockerfile", ["container-image"]),
25
+ ("*.dockerfile", "dockerfile", ["container-image"]),
26
+ ("docker-compose*.yml", "yaml", ["service-topology"]),
27
+ ("docker-compose*.yaml", "yaml", ["service-topology"]),
28
+ ("compose.yml", "yaml", ["service-topology"]),
29
+ ("compose.yaml", "yaml", ["service-topology"]),
30
+ ("k8s/*.yml", "yaml", ["service-topology"]),
31
+ ("k8s/*.yaml", "yaml", ["service-topology"]),
32
+ ("kind/*.yml", "yaml", ["service-topology"]),
33
+ ("kind/*.yaml", "yaml", ["service-topology"]),
34
+ ("Chart.yaml", "yaml", ["service-topology"]),
35
+ ("values.yaml", "yaml", ["service-topology"]),
36
+ ("config/*.yml", "yaml", ["tool-config"]),
37
+ ("config/*.yaml", "yaml", ["tool-config"]),
38
+ ("*.tf", "text", ["iac"]),
39
+ (".github/workflows/*.yml", "yaml", ["ci"]),
40
+ (".github/workflows/*.yaml", "yaml", ["ci"]),
41
+ (".gitlab-ci.yml", "yaml", ["ci"]),
42
+ (".env", "text", ["env"]),
43
+ (".env.*", "text", ["env"]),
44
+ (".flaskenv", "text", ["env"]),
45
+ ("tox.ini", "ini", ["tool-config"]),
46
+ ("noxfile.py", "text", ["tool-config"]),
47
+ ("Makefile", "text", ["tool-config"]),
48
+ ("MANIFEST.in", "text", ["packaging"]),
49
+ ("LICENSE*", "text", ["legal"]),
50
+ ("COPYRIGHT*", "text", ["legal"]),
51
+ ("NOTICE*", "text", ["legal"]),
52
+ ("*.md", "text", ["docs"]),
53
+ ("*.rst", "text", ["docs"]),
54
+ ("*.cfg", "ini", ["unknown"]),
55
+ ("*.toml", "toml", ["unknown"]),
56
+ ("*.properties", "properties", ["tool-config"]),
57
+ # Generic fallback AFTER the specific tox.ini rule above, so a non-tox
58
+ # *.ini file (mypy.ini, pytest.ini, ...) still reaches format="ini" --
59
+ # config-key extraction (#152) is namespace-eligible by format.
60
+ ("*.ini", "ini", ["tool-config"]),
61
+ ]
62
+
63
+ _IGNORED_DIRS = {
64
+ ".git", ".hg", ".svn", "__pycache__", ".venv", "venv", ".tox", ".nox",
65
+ "node_modules", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".idea",
66
+ "build", "dist", ".eggs", ".codeanalyzer", "virtualenv", "site-packages",
67
+ }
68
+
69
+
70
+ def _classify(rel_posix: str) -> Tuple[str, List[str]] | None:
71
+ name = rel_posix.rsplit("/", 1)[-1]
72
+ for pattern, fmt, roles in RULES:
73
+ target = rel_posix if ("/" in pattern or pattern.startswith("**")) else name
74
+ if fnmatch.fnmatch(target, pattern):
75
+ return fmt, roles
76
+ return None
77
+
78
+
79
+ def _capture_source(
80
+ raw: bytes, text: str, capture_text: bool, text_max_bytes: int
81
+ ) -> Tuple[str, bool]:
82
+ """Decide ``(source, text_truncated)`` for a decodable file.
83
+
84
+ Slices ``raw`` (not ``text``) for the cap, so it is a true byte cap even
85
+ when it lands inside a multi-byte character -- ``errors="ignore"`` drops
86
+ the dangling partial char at the cut, so this never raises."""
87
+ if not capture_text:
88
+ return "", False
89
+ if len(raw) <= text_max_bytes:
90
+ return text, False
91
+ return raw[:text_max_bytes].decode("utf-8", errors="ignore"), True
92
+
93
+
94
+ def discover_artifacts(
95
+ project_dir: Path,
96
+ app_name: str,
97
+ *,
98
+ capture_text: bool = True,
99
+ text_max_bytes: int = 262144,
100
+ ) -> Dict[str, PyArtifact]:
101
+ """Walk the project and return every file as an artifact, sorted by path.
102
+
103
+ Never-drop inventory (issue #157 follow-up): a rule-matched file keeps its
104
+ RULES format/roles; everything else falls back to ``text``/``["unknown"]``
105
+ (``source`` captured), or ``binary``/empty ``source`` when it is not UTF-8
106
+ decodable -- rule-matched but undecodable files downgrade to ``binary``
107
+ too, keeping the rule's roles. The one exclusion is a `.py` file no RULES
108
+ entry names: the symbol table already owns it. ``setup.py`` is the
109
+ deliberate exception -- it IS rule-matched (a dependency-manifest), so it
110
+ is captured like any other manifest despite the `.py` suffix.
111
+
112
+ ``capture_text=False`` empties ``source`` everywhere (inventory otherwise
113
+ identical); a decodable file over ``text_max_bytes`` gets a truncated
114
+ ``source`` and ``text_truncated=True`` -- except a ``dependency-manifest``
115
+ role artifact, which is always captured in full when decodable and
116
+ ``capture_text`` is on: its source is what ``build_dependency_view``
117
+ parses, not bulk/incidental content, so the byte cap does not apply to
118
+ it (``capture_text=False`` still empties it like everything else).
119
+ ``sha256``/``size_bytes`` always reflect the full file regardless of
120
+ either knob."""
121
+ out: Dict[str, PyArtifact] = {}
122
+ for path in sorted(project_dir.rglob("*")):
123
+ if not path.is_file():
124
+ continue
125
+ rel = path.relative_to(project_dir)
126
+ if any(part in _IGNORED_DIRS for part in rel.parts):
127
+ continue
128
+ rel_posix = rel.as_posix()
129
+ name = rel_posix.rsplit("/", 1)[-1]
130
+ hit = _classify(rel_posix)
131
+ if hit is None and name.endswith(".py"):
132
+ continue # symbol table's domain (setup.py is rule-matched above)
133
+
134
+ raw = path.read_bytes()
135
+ try:
136
+ text = raw.decode("utf-8")
137
+ decodable = True
138
+ except UnicodeDecodeError:
139
+ text, decodable = "", False
140
+
141
+ if hit is not None:
142
+ fmt, roles = hit
143
+ else:
144
+ fmt, roles = "text", ["unknown"]
145
+ # Extensionless shebang script (e.g. odoo-bin): no RULES glob can
146
+ # name these (nothing to match on but the shebang itself), so this
147
+ # is the one deterministic content-sniff refinement.
148
+ if decodable and "." not in name and text.startswith("#!"):
149
+ roles = ["script"]
150
+ if decodable:
151
+ # A dependency-manifest's source IS the extracted meaning (build_
152
+ # dependency_view parses it) -- the byte cap targets bulk/incidental
153
+ # assets, never the files extraction depends on, so manifests are
154
+ # exempt from it. capture_text=False still empties source (handled
155
+ # inside _capture_source); only the byte CAP is bypassed here.
156
+ cap = len(raw) if "dependency-manifest" in roles else text_max_bytes
157
+ source, text_truncated = _capture_source(raw, text, capture_text, cap)
158
+ else:
159
+ fmt, source, text_truncated = "binary", "", False
160
+
161
+ out[rel_posix] = PyArtifact(
162
+ id=artifact_id(app_name, rel_posix), path=rel_posix, format=fmt,
163
+ roles=list(roles), size_bytes=len(raw),
164
+ sha256=hashlib.sha256(raw).hexdigest(),
165
+ source=source, text_truncated=text_truncated,
166
+ )
167
+ return out
@@ -0,0 +1,248 @@
1
+ """Dependency-manifest readers. Pure text-in/records-out; no execution, no I/O."""
2
+
3
+ import ast
4
+ import configparser
5
+ import json
6
+ import re
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else: # pragma: no cover - exercised on the 3.10 CI leg
14
+ import tomli as tomllib
15
+
16
+ import yaml
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RawDep:
21
+ name: str # PEP 503 normalized
22
+ spec: str = ""
23
+ kind: str = "runtime" # runtime|dev|optional|build
24
+ extras: Tuple[str, ...] = ()
25
+
26
+
27
+ def normalize_name(raw: str) -> str:
28
+ return re.sub(r"[-_.]+", "-", raw).lower()
29
+
30
+
31
+ _REQ_LINE = re.compile(
32
+ r"^\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[(?P<extras>[^\]]+)\])?\s*(?P<spec>[^;#]*)"
33
+ )
34
+
35
+
36
+ def parse_requirement_line(line: str, kind: str = "runtime") -> Optional[RawDep]:
37
+ """One PEP 508-ish requirement line -> RawDep (None for options/paths/URLs)."""
38
+ line = line.split("#", 1)[0].strip()
39
+ if not line or line.startswith(("-", "--")) or line.startswith((".", "/")):
40
+ return None
41
+ if " @ " in line:
42
+ line = line.split(" @ ", 1)[0].strip() # direct ref (PEP 508): keep the name, drop the URL
43
+ elif "://" in line:
44
+ return None
45
+ m = _REQ_LINE.match(line)
46
+ if not m:
47
+ return None
48
+ extras = tuple(e.strip() for e in (m.group("extras") or "").split(",") if e.strip())
49
+ spec = m.group("spec").strip().rstrip(",")
50
+ if spec.endswith("\\"):
51
+ spec = spec[:-1].rstrip() # pip-compile --generate-hashes line continuation
52
+ return RawDep(normalize_name(m.group("name")), spec, kind, extras)
53
+
54
+
55
+ _REF_LINE = re.compile(r"^(?:-r|--requirement|-c|--constraint)\s+(\S+)")
56
+
57
+
58
+ def parse_requirement_refs(text: str) -> List[str]:
59
+ """-r/--requirement/-c/--constraint targets from a requirements file, in order."""
60
+ out = []
61
+ for line in text.splitlines():
62
+ line = line.split("#", 1)[0].strip()
63
+ m = _REF_LINE.match(line)
64
+ if m:
65
+ out.append(m.group(1))
66
+ return out
67
+
68
+
69
+ def _kind_for_requirements(basename: str) -> str:
70
+ return "dev" if re.search(r"\b(dev|test|lint|doc)\b", basename, re.I) else "runtime"
71
+
72
+
73
+ def _parse_requirements(basename: str, text: str) -> List[RawDep]:
74
+ kind = _kind_for_requirements(basename)
75
+ out = []
76
+ for line in text.splitlines():
77
+ dep = parse_requirement_line(line, kind)
78
+ if dep:
79
+ out.append(dep)
80
+ return out
81
+
82
+
83
+ def _spec_and_extras(spec) -> Tuple[str, Tuple[str, ...]]:
84
+ """Poetry/Pipfile dep spec (bare string or {version, extras} inline table) -> (version, extras)."""
85
+ if isinstance(spec, dict):
86
+ return spec.get("version", "") or "", tuple(spec.get("extras", []) or [])
87
+ return (spec if isinstance(spec, str) else ""), ()
88
+
89
+
90
+ def _parse_pyproject(text: str) -> List[RawDep]:
91
+ data = tomllib.loads(text)
92
+ out: List[RawDep] = []
93
+ for req in (data.get("build-system") or {}).get("requires", []):
94
+ d = parse_requirement_line(req, "build")
95
+ if d:
96
+ out.append(d)
97
+ proj = data.get("project") or {}
98
+ for req in proj.get("dependencies", []):
99
+ d = parse_requirement_line(req)
100
+ if d:
101
+ out.append(d)
102
+ for group in (proj.get("optional-dependencies") or {}).values():
103
+ for req in group:
104
+ d = parse_requirement_line(req, "optional")
105
+ if d:
106
+ out.append(d)
107
+ poetry = ((data.get("tool") or {}).get("poetry")) or {}
108
+ for name, spec in (poetry.get("dependencies") or {}).items():
109
+ if normalize_name(name) == "python":
110
+ continue
111
+ v, ex = _spec_and_extras(spec)
112
+ out.append(RawDep(normalize_name(name), v, "runtime", ex))
113
+ for gname, group in (poetry.get("group") or {}).items():
114
+ kind = "dev" if gname == "dev" else "optional"
115
+ for name, spec in (group.get("dependencies") or {}).items():
116
+ v, ex = _spec_and_extras(spec)
117
+ out.append(RawDep(normalize_name(name), v, kind, ex))
118
+ for name, spec in (poetry.get("dev-dependencies") or {}).items(): # legacy poetry
119
+ v, ex = _spec_and_extras(spec)
120
+ out.append(RawDep(normalize_name(name), v, "dev", ex))
121
+ return out
122
+
123
+
124
+ def _parse_setup_py(text: str) -> Tuple[List[RawDep], bool]:
125
+ """Static AST only. Literal lists lift; anything computed -> partial=True."""
126
+ try:
127
+ tree = ast.parse(text)
128
+ except SyntaxError:
129
+ return [], True
130
+ out: List[RawDep] = []
131
+ partial = False
132
+ for node in ast.walk(tree):
133
+ if not (isinstance(node, ast.Call) and getattr(node.func, "id", getattr(node.func, "attr", "")) == "setup"):
134
+ continue
135
+ for kw in node.keywords:
136
+ if kw.arg == "install_requires":
137
+ lifted = _lift_str_list(kw.value)
138
+ if lifted is None:
139
+ partial = True
140
+ else:
141
+ out += [d for d in (parse_requirement_line(s) for s in lifted) if d]
142
+ elif kw.arg == "extras_require":
143
+ if not isinstance(kw.value, ast.Dict):
144
+ partial = True
145
+ continue
146
+ for v in kw.value.values:
147
+ lifted = _lift_str_list(v)
148
+ if lifted is None:
149
+ partial = True
150
+ else:
151
+ out += [d for d in (parse_requirement_line(s, "optional") for s in lifted) if d]
152
+ return out, partial
153
+
154
+
155
+ def _lift_str_list(node: ast.AST) -> Optional[List[str]]:
156
+ if isinstance(node, (ast.List, ast.Tuple)) and all(
157
+ isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.elts
158
+ ):
159
+ return [e.value for e in node.elts]
160
+ return None
161
+
162
+
163
+ def _parse_setup_cfg(text: str) -> List[RawDep]:
164
+ cp = configparser.ConfigParser()
165
+ cp.read_string(text)
166
+ out: List[RawDep] = []
167
+ if cp.has_option("options", "install_requires"):
168
+ for line in cp.get("options", "install_requires").splitlines():
169
+ d = parse_requirement_line(line)
170
+ if d:
171
+ out.append(d)
172
+ if cp.has_section("options.extras_require"):
173
+ for _, val in cp.items("options.extras_require"):
174
+ for line in val.splitlines():
175
+ d = parse_requirement_line(line, "optional")
176
+ if d:
177
+ out.append(d)
178
+ return out
179
+
180
+
181
+ def _parse_pipfile(text: str) -> List[RawDep]:
182
+ data = tomllib.loads(text)
183
+ out: List[RawDep] = []
184
+ for section, kind in (("packages", "runtime"), ("dev-packages", "dev")):
185
+ for name, spec in (data.get(section) or {}).items():
186
+ v, ex = _spec_and_extras(spec)
187
+ out.append(RawDep(normalize_name(name), "" if v == "*" else v, kind, ex))
188
+ return out
189
+
190
+
191
+ def _parse_environment_yml(text: str) -> List[RawDep]:
192
+ data = yaml.safe_load(text) or {}
193
+ out: List[RawDep] = []
194
+ for item in data.get("dependencies") or []:
195
+ if isinstance(item, str):
196
+ d = parse_requirement_line(item)
197
+ if d and d.name not in ("pip", "python"):
198
+ out.append(d)
199
+ elif isinstance(item, dict):
200
+ for req in item.get("pip") or []:
201
+ d = parse_requirement_line(req)
202
+ if d:
203
+ out.append(d)
204
+ return out
205
+
206
+
207
+ def parse_manifest(path: str, text: str) -> Tuple[List[RawDep], bool]:
208
+ """Dispatch on basename -> (records, partial). Unknown basenames -> ([], False)."""
209
+ base = path.rsplit("/", 1)[-1]
210
+ try:
211
+ if base.startswith("requirements") and base.endswith(".txt"):
212
+ return _parse_requirements(base, text), False
213
+ if base == "pyproject.toml":
214
+ return _parse_pyproject(text), False
215
+ if base == "setup.py":
216
+ return _parse_setup_py(text)
217
+ if base == "setup.cfg":
218
+ return _parse_setup_cfg(text), False
219
+ if base == "Pipfile":
220
+ return _parse_pipfile(text), False
221
+ if base in ("environment.yml", "environment.yaml"):
222
+ return _parse_environment_yml(text), False
223
+ except Exception:
224
+ return [], True # unparseable manifest: keep the artifact, flag extraction
225
+ return [], False
226
+
227
+
228
+ def parse_lock_pins(path: str, text: str) -> Dict[str, str]:
229
+ """Lock file -> {normalized name: pinned version}. Never creates records."""
230
+ base = path.rsplit("/", 1)[-1]
231
+ try:
232
+ if base in ("poetry.lock", "uv.lock"):
233
+ data = tomllib.loads(text)
234
+ return {
235
+ normalize_name(p["name"]): str(p["version"])
236
+ for p in data.get("package") or [] if "name" in p and "version" in p
237
+ }
238
+ if base == "Pipfile.lock":
239
+ data = json.loads(text)
240
+ out = {}
241
+ for section in ("default", "develop"):
242
+ for name, meta in (data.get(section) or {}).items():
243
+ v = (meta or {}).get("version", "")
244
+ out[normalize_name(name)] = v.lstrip("=")
245
+ return out
246
+ except Exception:
247
+ return {}
248
+ return {}