|
| 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