codeanalyzer-python 1.2.0__py3-none-any.whl → 1.4.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.
- codeanalyzer/__main__.py +24 -1
- codeanalyzer/artifacts/__init__.py +20 -0
- codeanalyzer/artifacts/config_keys.py +588 -0
- codeanalyzer/artifacts/config_use.py +597 -0
- codeanalyzer/artifacts/config_use_rules.yml +58 -0
- codeanalyzer/artifacts/dependencies.py +237 -0
- codeanalyzer/artifacts/discovery.py +141 -0
- codeanalyzer/artifacts/parsers.py +248 -0
- codeanalyzer/core.py +90 -0
- codeanalyzer/dataflow/builder.py +22 -3
- codeanalyzer/dataflow/sdg.py +17 -2
- codeanalyzer/dataflow/summaries.py +27 -4
- codeanalyzer/neo4j/bolt.py +35 -6
- codeanalyzer/neo4j/emit.py +3 -2
- codeanalyzer/neo4j/project.py +191 -0
- codeanalyzer/neo4j/schema.py +74 -0
- codeanalyzer/options/options.py +4 -0
- codeanalyzer/schema/ids.py +21 -0
- codeanalyzer/schema/py_schema.py +117 -0
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +11 -0
- {codeanalyzer_python-1.2.0.dist-info → codeanalyzer_python-1.4.0.dist-info}/METADATA +117 -7
- {codeanalyzer_python-1.2.0.dist-info → codeanalyzer_python-1.4.0.dist-info}/RECORD +26 -19
- {codeanalyzer_python-1.2.0.dist-info → codeanalyzer_python-1.4.0.dist-info}/WHEEL +0 -0
- {codeanalyzer_python-1.2.0.dist-info → codeanalyzer_python-1.4.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-1.2.0.dist-info → codeanalyzer_python-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-1.2.0.dist-info → codeanalyzer_python-1.4.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -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 emptied by ``capture_text=False`` (a payload-size control on the
|
|
85
|
+
JSON/Neo4j payload, not an extraction control). Read the real file fresh
|
|
86
|
+
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,141 @@
|
|
|
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 discover_artifacts(
|
|
80
|
+
project_dir: Path,
|
|
81
|
+
app_name: str,
|
|
82
|
+
*,
|
|
83
|
+
capture_text: bool = True,
|
|
84
|
+
) -> Dict[str, PyArtifact]:
|
|
85
|
+
"""Walk the project and return every file as an artifact, sorted by path.
|
|
86
|
+
|
|
87
|
+
Never-drop inventory (issue #157 follow-up): a rule-matched file keeps its
|
|
88
|
+
RULES format/roles; everything else falls back to ``text``/``["unknown"]``
|
|
89
|
+
(``source`` captured), or ``binary``/empty ``source`` when it is not UTF-8
|
|
90
|
+
decodable -- rule-matched but undecodable files downgrade to ``binary``
|
|
91
|
+
too, keeping the rule's roles. The one exclusion is a `.py` file no RULES
|
|
92
|
+
entry names: the symbol table already owns it. ``setup.py`` is the
|
|
93
|
+
deliberate exception -- it IS rule-matched (a dependency-manifest), so it
|
|
94
|
+
is captured like any other manifest despite the `.py` suffix.
|
|
95
|
+
|
|
96
|
+
``source`` is the WHOLE file or nothing -- never a prefix (#172). A
|
|
97
|
+
decodable file is captured in full; ``capture_text=False`` empties
|
|
98
|
+
``source`` everywhere (inventory otherwise identical), and an undecodable
|
|
99
|
+
file gets ``""`` as ``binary``. ``sha256``/``size_bytes`` always reflect
|
|
100
|
+
the full file regardless."""
|
|
101
|
+
out: Dict[str, PyArtifact] = {}
|
|
102
|
+
for path in sorted(project_dir.rglob("*")):
|
|
103
|
+
if not path.is_file():
|
|
104
|
+
continue
|
|
105
|
+
rel = path.relative_to(project_dir)
|
|
106
|
+
if any(part in _IGNORED_DIRS for part in rel.parts):
|
|
107
|
+
continue
|
|
108
|
+
rel_posix = rel.as_posix()
|
|
109
|
+
name = rel_posix.rsplit("/", 1)[-1]
|
|
110
|
+
hit = _classify(rel_posix)
|
|
111
|
+
if hit is None and name.endswith(".py"):
|
|
112
|
+
continue # symbol table's domain (setup.py is rule-matched above)
|
|
113
|
+
|
|
114
|
+
raw = path.read_bytes()
|
|
115
|
+
try:
|
|
116
|
+
text = raw.decode("utf-8")
|
|
117
|
+
decodable = True
|
|
118
|
+
except UnicodeDecodeError:
|
|
119
|
+
text, decodable = "", False
|
|
120
|
+
|
|
121
|
+
if hit is not None:
|
|
122
|
+
fmt, roles = hit
|
|
123
|
+
else:
|
|
124
|
+
fmt, roles = "text", ["unknown"]
|
|
125
|
+
# Extensionless shebang script (e.g. odoo-bin): no RULES glob can
|
|
126
|
+
# name these (nothing to match on but the shebang itself), so this
|
|
127
|
+
# is the one deterministic content-sniff refinement.
|
|
128
|
+
if decodable and "." not in name and text.startswith("#!"):
|
|
129
|
+
roles = ["script"]
|
|
130
|
+
if decodable:
|
|
131
|
+
source = text if capture_text else ""
|
|
132
|
+
else:
|
|
133
|
+
fmt, source = "binary", ""
|
|
134
|
+
|
|
135
|
+
out[rel_posix] = PyArtifact(
|
|
136
|
+
id=artifact_id(app_name, rel_posix), path=rel_posix, format=fmt,
|
|
137
|
+
roles=list(roles), size_bytes=len(raw),
|
|
138
|
+
sha256=hashlib.sha256(raw).hexdigest(),
|
|
139
|
+
source=source,
|
|
140
|
+
)
|
|
141
|
+
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 {}
|