From db8155e1834d697a190c5fd74a3063af33500899 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Jan 2026 05:20:00 -0600 Subject: [PATCH 1/4] py(deps): Add typing_extensions for Python <3.11 why: NotRequired and TypedDict from typing_extensions are used at runtime in src/vcspull/types.py but were only available as dev dependencies. what: - Add typing_extensions>=4.0.0 with python_version<'3.11' marker - Python 3.11+ has these in stdlib typing module --- pyproject.toml | 3 ++- uv.lock | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cfd817f9..8267b5f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,8 @@ homepage = "https://vcspull.git-pull.com" dependencies = [ "libvcs~=0.38.1", "colorama>=0.3.9", - "PyYAML>=6.0" + "PyYAML>=6.0", + "typing_extensions>=4.0.0;python_version<'3.11'", # NotRequired, TypedDict ] [project-urls] diff --git a/uv.lock b/uv.lock index 292a6517..62f09938 100644 --- a/uv.lock +++ b/uv.lock @@ -1437,6 +1437,7 @@ dependencies = [ { name = "colorama" }, { name = "libvcs" }, { name = "pyyaml" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] [package.dev-dependencies] @@ -1521,6 +1522,7 @@ requires-dist = [ { name = "colorama", specifier = ">=0.3.9" }, { name = "libvcs", specifier = "~=0.38.1" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.0.0" }, ] [package.metadata.requires-dev] From c36513e5c1e7fcca394b6a17278f570ff97b0e35 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Jan 2026 05:20:08 -0600 Subject: [PATCH 2/4] .github/scripts(feat): Add check_deps.py for undeclared imports why: Detect runtime imports not declared in pyproject.toml dependencies without relying on external tools (supply chain security). what: - Zero-dependency script using only stdlib (ast, tomllib, sys, pathlib) - Parses pyproject.toml dependencies and extracts imports via AST - Handles package->import name mapping (PyYAML->yaml, etc.) - Includes doctests for key functions --- .github/scripts/check_deps.py | 321 ++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 .github/scripts/check_deps.py diff --git a/.github/scripts/check_deps.py b/.github/scripts/check_deps.py new file mode 100644 index 00000000..aea2852e --- /dev/null +++ b/.github/scripts/check_deps.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python +"""Check for undeclared runtime dependencies. + +A minimal, zero-dependency script to detect imports in src/ that aren't +declared in pyproject.toml dependencies. + +Examples +-------- +>>> # Test normalize_package_name +>>> normalize_package_name("PyYAML") +'pyyaml' +>>> normalize_package_name("typing-extensions") +'typing_extensions' +>>> normalize_package_name("Pillow") +'pillow' + +>>> # Test parse_dependency_spec +>>> parse_dependency_spec("requests>=2.0") +'requests' +>>> parse_dependency_spec("PyYAML>=6.0") +'pyyaml' +>>> parse_dependency_spec("typing_extensions>=4.0.0;python_version<'3.11'") +'typing_extensions' +>>> parse_dependency_spec("package[extra]>=1.0") +'package' +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +import tomllib + +# Known package name -> import name mappings +# (where they differ) +PACKAGE_TO_IMPORT: dict[str, str] = { + "pyyaml": "yaml", + "pillow": "PIL", + "beautifulsoup4": "bs4", + "scikit-learn": "sklearn", + "opencv-python": "cv2", + "typing_extensions": "typing_extensions", +} + +# Imports to ignore (optional deps, usually wrapped in try/except) +IGNORED_IMPORTS: set[str] = { + "shtab", # Optional shell completion +} + + +def normalize_package_name(name: str) -> str: + """Normalize package name per PEP 503. + + Parameters + ---------- + name : str + Package name from pyproject.toml + + Returns + ------- + str + Normalized lowercase name with hyphens as underscores + + Examples + -------- + >>> normalize_package_name("PyYAML") + 'pyyaml' + >>> normalize_package_name("typing-extensions") + 'typing_extensions' + """ + return name.lower().replace("-", "_") + + +def parse_dependency_spec(spec: str) -> str: + """Extract package name from dependency specification. + + Parameters + ---------- + spec : str + Dependency spec like "requests>=2.0" or "pkg[extra];python_version<'3.11'" + + Returns + ------- + str + Normalized package name + + Examples + -------- + >>> parse_dependency_spec("requests>=2.0") + 'requests' + >>> parse_dependency_spec("package[extra]>=1.0,<2.0") + 'package' + >>> parse_dependency_spec("typing_extensions;python_version<'3.11'") + 'typing_extensions' + """ + # Remove environment markers (;python_version<'3.11') + name = spec.split(";")[0].strip() + # Remove extras ([extra]) + name = name.split("[")[0] + # Remove version specifiers (>=, <=, ==, ~=, !=, <, >) + for sep in (">=", "<=", "==", "~=", "!=", "<", ">", " "): + name = name.split(sep)[0] + return normalize_package_name(name.strip()) + + +def get_declared_deps(pyproject_path: Path) -> dict[str, str]: + """Get declared dependencies from pyproject.toml. + + Parameters + ---------- + pyproject_path : Path + Path to pyproject.toml + + Returns + ------- + dict[str, str] + Mapping of normalized package name to expected import name + """ + data = tomllib.loads(pyproject_path.read_text()) + deps = data.get("project", {}).get("dependencies", []) + + result = {} + for spec in deps: + pkg_name = parse_dependency_spec(spec) + # Use known mapping or assume import name = normalized package name + import_name = PACKAGE_TO_IMPORT.get(pkg_name, pkg_name) + result[pkg_name] = import_name + + return result + + +def get_stdlib_modules() -> set[str]: + """Get set of standard library module names. + + Returns + ------- + set[str] + Standard library top-level module names + + Examples + -------- + >>> stdlib = get_stdlib_modules() + >>> "os" in stdlib + True + >>> "pathlib" in stdlib + True + >>> "requests" in stdlib + False + """ + # Python 3.10+ has sys.stdlib_module_names + if hasattr(sys, "stdlib_module_names"): + return set(sys.stdlib_module_names) + + # Fallback for older Python (shouldn't hit this for vcspull) + import sysconfig + + stdlib_path = Path(sysconfig.get_paths()["stdlib"]) + modules = {p.stem for p in stdlib_path.glob("*.py")} + modules |= { + p.name + for p in stdlib_path.iterdir() + if p.is_dir() and not p.name.startswith("_") + } + return modules + + +def extract_imports(source_dir: Path) -> dict[str, list[tuple[Path, int]]]: + """Extract all imports from Python files in directory. + + Parameters + ---------- + source_dir : Path + Directory to scan for .py files + + Returns + ------- + dict[str, list[tuple[Path, int]]] + Mapping of import name to list of (file, line_number) locations + """ + imports: dict[str, list[tuple[Path, int]]] = {} + + for py_file in source_dir.rglob("*.py"): + try: + tree = ast.parse(py_file.read_text(), filename=str(py_file)) + except SyntaxError: + print(f"Warning: Could not parse {py_file}", file=sys.stderr) + continue + + for node in ast.walk(tree): + name = None + lineno = 0 + + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.name.split(".")[0] + lineno = node.lineno + imports.setdefault(name, []).append((py_file, lineno)) + + # Skip relative imports (from . import x) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + name = node.module.split(".")[0] + lineno = node.lineno + imports.setdefault(name, []).append((py_file, lineno)) + + return imports + + +def find_first_party_modules(source_dir: Path) -> set[str]: + """Find first-party module names (the project's own packages). + + Parameters + ---------- + source_dir : Path + Source directory (e.g., src/) + + Returns + ------- + set[str] + Set of first-party module names + """ + modules = set() + for item in source_dir.iterdir(): + if item.is_dir() and (item / "__init__.py").exists(): + modules.add(item.name) + elif item.suffix == ".py" and item.stem != "__init__": + modules.add(item.stem) + return modules + + +def check_undeclared( + source_dir: Path, + pyproject_path: Path, + ignore: set[str] | None = None, +) -> list[tuple[str, list[tuple[Path, int]]]]: + """Find imports not declared in dependencies. + + Parameters + ---------- + source_dir : Path + Directory containing source code + pyproject_path : Path + Path to pyproject.toml + ignore : set[str], optional + Additional import names to ignore + + Returns + ------- + list[tuple[str, list[tuple[Path, int]]]] + List of (import_name, locations) for undeclared imports + """ + ignore = (ignore or set()) | IGNORED_IMPORTS + + declared = get_declared_deps(pyproject_path) + declared_imports = set(declared.values()) + + imports = extract_imports(source_dir) + stdlib = get_stdlib_modules() + first_party = find_first_party_modules(source_dir) + + undeclared = [] + for name, locations in sorted(imports.items()): + # Skip if: stdlib, first-party, declared, or ignored + if name in stdlib: + continue + if name in first_party: + continue + if name in declared_imports: + continue + if name in ignore: + continue + + undeclared.append((name, locations)) + + return undeclared + + +def main() -> int: + """Run the dependency checker. + + Returns + ------- + int + Exit code (0 = success, 1 = undeclared deps found) + """ + # Find project root (where pyproject.toml is) + script_dir = Path(__file__).resolve().parent # .github/scripts/ + project_root = script_dir.parent.parent # repo root + pyproject_path = project_root / "pyproject.toml" + source_dir = project_root / "src" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found", file=sys.stderr) + return 1 + + if not source_dir.exists(): + print(f"Error: {source_dir} not found", file=sys.stderr) + return 1 + + undeclared = check_undeclared(source_dir, pyproject_path) + + if not undeclared: + print("No undeclared dependencies detected.") + return 0 + + print("Undeclared dependencies found:\n") + for name, locations in undeclared: + print(f" {name}") + for path, lineno in locations[:3]: # Show first 3 locations + rel_path = path.relative_to(project_root) + print(f" {rel_path}:{lineno}") + if len(locations) > 3: + print(f" ... and {len(locations) - 3} more") + print() + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From db41a53c8128a723f872c4490fc14a3122d60933 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Jan 2026 05:20:14 -0600 Subject: [PATCH 3/4] ci(tests.yml): Add runtime dependency check step why: Catch undeclared imports before they cause runtime failures. what: - Add "Check runtime dependencies" step after dependency install - Runs .github/scripts/check_deps.py to detect undeclared imports --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 804d6ef2..5ec0ffa5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,9 @@ jobs: python -V uv run python -V + - name: Check runtime dependencies + run: python .github/scripts/check_deps.py + - name: Lint with ruff check run: uv run ruff check . From f91a316d4be9d660f2a0db0dc641ecc03f399952 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Jan 2026 05:20:19 -0600 Subject: [PATCH 4/4] justfile(feat): Add check-deps command why: Allow local verification of runtime dependencies. what: - Add check-deps command in lint group - Runs .github/scripts/check_deps.py --- justfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/justfile b/justfile index 1ba127d1..f6f9259a 100644 --- a/justfile +++ b/justfile @@ -76,6 +76,11 @@ watch-ruff: mypy: uv run mypy $({{ py_files }}) +# Check for undeclared runtime dependencies +[group: 'lint'] +check-deps: + python .github/scripts/check_deps.py + # Watch files and run mypy on change [group: 'lint'] watch-mypy: