Skip to content

Commit d5fd801

Browse files
committed
ci(deptry): parallelize with a matrix and a Python scan script (#7161)
Split the deptry logic into .github/scripts/deptry_scan.py (JSON output) and scan each changed connector in parallel via a discover -> matrix -> report workflow. Distinguish 'deptry could not run' (env build failure -> exit 2, blocking) from 'no findings'. Cover connectors declaring dependencies via pyproject.toml (generate a requirements file from [project.dependencies]) in addition to requirements.txt, scan the whole connector root so every layout is handled, and detect first-party modules across it. Drop the workflow/map files from the path trigger.
1 parent e8ba936 commit d5fd801

2 files changed

Lines changed: 363 additions & 134 deletions

File tree

.github/scripts/deptry_scan.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#!/usr/bin/env python3
2+
"""Scan a single connector for dependency issues with deptry.
3+
4+
Usage: deptry_scan.py <connector-dir> # e.g. external-import/foo
5+
6+
Environment:
7+
DEPTRY_MAP_FILE package/module map + ignore list (default: .github/deptry-package-map.txt)
8+
DEPTRY_RESULTS_DIR directory where <name>.err / <name>.warn are written (default: a temp dir)
9+
10+
deptry runs inside an isolated uv environment with the connector's own
11+
dependencies installed, so transitive imports (provided by pycti) are reported
12+
as DEP003 instead of false DEP001.
13+
14+
Exit codes:
15+
0 deptry ran; no undeclared dependency (DEP001)
16+
1 deptry ran; at least one undeclared dependency (DEP001) -> blocking
17+
2 deptry could not run (environment build failure / crash) -> blocking
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
import os
24+
import re
25+
import subprocess
26+
import sys
27+
import tempfile
28+
import tomllib
29+
from pathlib import Path
30+
31+
DEPTRY_VERSION = "deptry==0.25.1"
32+
33+
34+
def annotate(line: str) -> None:
35+
"""Emit a GitHub Actions annotation only when running inside Actions."""
36+
if os.environ.get("GITHUB_ACTIONS"):
37+
print(line)
38+
39+
40+
def load_config(map_file: Path) -> tuple[str, str]:
41+
"""Return (package->module map, DEP002 ignore list) as deptry CLI strings.
42+
43+
deptry MAPPING options replace the whole dict on each flag, so all entries
44+
must be joined into a single argument.
45+
"""
46+
map_entries: list[str] = []
47+
ignore_pkgs: list[str] = []
48+
if map_file.is_file():
49+
for raw in map_file.read_text().splitlines():
50+
line = raw.split("#", 1)[0].strip()
51+
if not line:
52+
continue
53+
if line.startswith("ignore="):
54+
ignore_pkgs.append(line[len("ignore=") :])
55+
else:
56+
map_entries.append(line)
57+
return ",".join(map_entries), "|".join(ignore_pkgs)
58+
59+
60+
# Directories that never contain first-party source (also excluded from deptry).
61+
SKIP_DIRS = {
62+
".git",
63+
".venv",
64+
"venv",
65+
"env",
66+
"__pycache__",
67+
"node_modules",
68+
"build",
69+
"dist",
70+
".mypy_cache",
71+
".pytest_cache",
72+
".ruff_cache",
73+
}
74+
75+
76+
def first_party_modules(root: Path) -> list[str]:
77+
"""Every local package/module name anywhere in the connector, so intra-connector
78+
imports (even across non-standard layouts) are never mistaken for missing
79+
dependencies."""
80+
names = {"src"}
81+
for path in root.rglob("*"):
82+
parts = path.relative_to(root).parts
83+
if any(
84+
p in SKIP_DIRS or p.startswith(".") or p.endswith(".egg-info")
85+
for p in parts
86+
):
87+
continue
88+
if path.is_dir():
89+
names.add(path.name)
90+
elif path.suffix == ".py":
91+
names.add(path.stem)
92+
return sorted(names)
93+
94+
95+
def declared_line(declared_in: Path, package: str) -> int:
96+
"""First line mentioning the package in the dependency file (deptry does not
97+
report a line for DEP002). Works for both requirements.txt and pyproject.toml."""
98+
pattern = re.compile(
99+
rf"""(^|[\s"']){re.escape(package)}([\s"'\[=<>~!]|$)""", re.IGNORECASE
100+
)
101+
for i, line in enumerate(declared_in.read_text().splitlines(), start=1):
102+
if pattern.search(line):
103+
return i
104+
return 1
105+
106+
107+
def resolve_requirements(
108+
connector: Path, tmp: Path
109+
) -> tuple[Path, Path] | tuple[None, None]:
110+
"""Return (requirements_file, declared_in) for the connector.
111+
112+
Uses a requirements.txt when present, otherwise generates one from the
113+
[project.dependencies] of a pyproject.toml, so connectors that declare their
114+
dependencies there are covered too.
115+
"""
116+
for req in (connector / "src" / "requirements.txt", connector / "requirements.txt"):
117+
if req.is_file():
118+
return req, req
119+
120+
for pyproject in (
121+
connector / "pyproject.toml",
122+
connector / "src" / "pyproject.toml",
123+
):
124+
if not pyproject.is_file():
125+
continue
126+
deps = (
127+
tomllib.loads(pyproject.read_text())
128+
.get("project", {})
129+
.get("dependencies", [])
130+
)
131+
if not deps:
132+
continue
133+
generated = tmp / "requirements.txt"
134+
generated.write_text("\n".join(deps) + "\n")
135+
return generated, pyproject
136+
137+
return None, None
138+
139+
140+
def run_deptry(src: Path, req: Path, tmp: Path) -> list[dict] | None:
141+
"""Run deptry in an isolated uv env; return parsed issues, or None if it
142+
could not run (environment build failure / crash)."""
143+
package_map, dep002_ignores = load_config(
144+
Path(os.environ.get("DEPTRY_MAP_FILE", ".github/deptry-package-map.txt"))
145+
)
146+
# __main__ is a pydantic BaseSettings false positive; DEP002 ignores from config.
147+
per_rule = "DEP001=__main__"
148+
if dep002_ignores:
149+
per_rule += f",DEP002={dep002_ignores}"
150+
151+
kf_args: list[str] = []
152+
for module in first_party_modules(src):
153+
kf_args += ["--known-first-party", module]
154+
155+
report = tmp / "report.json"
156+
cmd = [
157+
"uv",
158+
"run",
159+
"--isolated",
160+
"--no-project",
161+
"--with-requirements",
162+
str(req),
163+
"--with",
164+
DEPTRY_VERSION,
165+
"--",
166+
"deptry",
167+
str(src),
168+
"--requirements-files",
169+
str(req),
170+
"--json-output",
171+
str(report),
172+
"--extend-exclude",
173+
r".*/tests?/.*",
174+
"--extend-exclude",
175+
r".*_tests?/.*",
176+
"--extend-exclude",
177+
r".*\.egg-info/.*",
178+
"--per-rule-ignores",
179+
per_rule,
180+
*kf_args,
181+
]
182+
if package_map:
183+
cmd += ["--package-module-name-map", package_map]
184+
185+
proc = subprocess.run(cmd, capture_output=True, text=True)
186+
187+
# deptry writes the JSON report (even "[]") whenever it runs. A missing or
188+
# invalid report means the environment failed to build or deptry crashed.
189+
try:
190+
return json.loads(report.read_text())
191+
except (FileNotFoundError, json.JSONDecodeError):
192+
sys.stdout.write(proc.stdout)
193+
sys.stderr.write(proc.stderr)
194+
return None
195+
196+
197+
def main() -> int:
198+
if len(sys.argv) != 2:
199+
print("usage: deptry_scan.py <connector-dir>", file=sys.stderr)
200+
return 2
201+
202+
connector = sys.argv[1].rstrip("/")
203+
connector_path = Path(connector)
204+
name = connector.replace("/", "_")
205+
results_dir = Path(os.environ.get("DEPTRY_RESULTS_DIR") or tempfile.mkdtemp())
206+
207+
with tempfile.TemporaryDirectory() as tmp_dir:
208+
tmp = Path(tmp_dir)
209+
req, declared_in = resolve_requirements(connector_path, tmp)
210+
if req is None:
211+
print(
212+
f"No requirements.txt or pyproject.toml deps for {connector}, skipping."
213+
)
214+
return 0
215+
# Scan the whole connector so every layout is covered; tests, venv and
216+
# build artefacts are excluded by deptry.
217+
issues = run_deptry(connector_path, req, tmp)
218+
219+
if issues is None:
220+
annotate(
221+
f"::error title=deptry could not run::Failed to build the "
222+
f"environment or run deptry for {connector}"
223+
)
224+
print(f"deptry did not run for {connector}", file=sys.stderr)
225+
return 2
226+
227+
results_dir.mkdir(parents=True, exist_ok=True)
228+
err_file = results_dir / f"{name}.err"
229+
warn_file = results_dir / f"{name}.warn"
230+
has_dep001 = False
231+
232+
for issue in issues:
233+
code = issue.get("error", {}).get("code", "")
234+
module = issue.get("module", "")
235+
loc = issue.get("location") or {}
236+
file = loc.get("file") or str(declared_in)
237+
line = loc.get("line") or 1
238+
239+
if code == "DEP001":
240+
annotate(
241+
f"::error file={file},line={line},title=Undeclared dependency::"
242+
f"{module} is imported but not declared in {declared_in}"
243+
)
244+
with err_file.open("a") as fh:
245+
fh.write(
246+
f"- `{module}` (DEP001) in `{connector}`: "
247+
f"imported but not declared in `{declared_in}`\n"
248+
)
249+
has_dep001 = True
250+
elif code == "DEP003":
251+
annotate(
252+
f"::warning file={file},line={line},title=Transitive dependency::"
253+
f"{module} is imported but only available transitively; add it to {declared_in}"
254+
)
255+
with warn_file.open("a") as fh:
256+
fh.write(f"- `{module}` (DEP003, transitive) in `{declared_in}`\n")
257+
elif code == "DEP002":
258+
line = declared_line(declared_in, module)
259+
annotate(
260+
f"::warning file={declared_in},line={line},title=Unused dependency::"
261+
f"{module} is listed in {declared_in} but not imported in {connector}"
262+
)
263+
with warn_file.open("a") as fh:
264+
fh.write(f"- `{module}` (DEP002, unused) in `{declared_in}`\n")
265+
266+
return 1 if has_dep001 else 0
267+
268+
269+
if __name__ == "__main__":
270+
sys.exit(main())

0 commit comments

Comments
 (0)