Created
August 3, 2026 15:15
-
-
Save mgaitan/e8767b5597fc42407a5757695595a74d to your computer and use it in GitHub Desktop.
Fierro: detectar módulos Python candidatos a desuso
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """List Python modules that appear unused according to Ruff's import graph. | |
| The result is intentionally conservative: modules used as scripts or mentioned | |
| by non-Python files are excluded. Use ``--delete`` only after reviewing the | |
| default output. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import re | |
| import subprocess | |
| import sys | |
| import tomllib | |
| from collections.abc import Iterable | |
| from pathlib import Path | |
| REPOSITORY_ROOT = Path(__file__).resolve().parent.parent | |
| MAIN_GUARD = re.compile(r"if\s+__name__\s*==\s*['\"]__main__['\"]") | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument( | |
| "path", | |
| nargs="?", | |
| default=REPOSITORY_ROOT, | |
| type=Path, | |
| help="Subtree to list and delete candidates from (the whole repository is still analyzed).", | |
| ) | |
| parser.add_argument( | |
| "--delete", | |
| action="store_true", | |
| help="Delete the listed candidates.", | |
| ) | |
| parser.add_argument( | |
| "--exclude", | |
| action="append", | |
| default=[], | |
| metavar="PATH", | |
| type=Path, | |
| help="Exclude a path from the output and deletion (can be repeated).", | |
| ) | |
| return parser.parse_args() | |
| def resolve_target(path: Path) -> Path: | |
| target = path.resolve() | |
| try: | |
| target.relative_to(REPOSITORY_ROOT) | |
| except ValueError as error: | |
| raise ValueError(f"{target} is outside the repository") from error | |
| if not target.exists(): | |
| raise ValueError(f"{target} does not exist") | |
| return target | |
| def pytest_testpaths() -> list[Path]: | |
| pyproject = tomllib.loads((REPOSITORY_ROOT / "pyproject.toml").read_text()) | |
| testpaths = pyproject.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("testpaths", []) | |
| paths = [] | |
| for testpath in testpaths: | |
| paths.extend(path.resolve() for path in REPOSITORY_ROOT.glob(testpath)) | |
| return paths | |
| def ruff_dependents() -> dict[Path, list[Path]]: | |
| command = [ | |
| "uv", | |
| "run", | |
| "ruff", | |
| "analyze", | |
| "graph", | |
| "--direction", | |
| "dependents", | |
| "--detect-string-imports", | |
| ".", | |
| ] | |
| result = subprocess.run(command, cwd=REPOSITORY_ROOT, text=True, capture_output=True) | |
| if result.returncode: | |
| raise RuntimeError(result.stderr.strip() or "ruff analyze graph failed") | |
| try: | |
| graph = json.loads(result.stdout) | |
| except json.JSONDecodeError as error: | |
| raise RuntimeError("ruff analyze graph did not return JSON") from error | |
| return {Path(module): [Path(dependent) for dependent in dependents] for module, dependents in graph.items()} | |
| def is_script(module: Path) -> bool: | |
| source = module.read_text(encoding="utf-8", errors="replace") | |
| return source.startswith("#!") or MAIN_GUARD.search(source) is not None | |
| def non_python_references(candidates: Iterable[Path]) -> set[Path]: | |
| candidates = list(candidates) | |
| paths_by_name: dict[str, list[Path]] = {} | |
| for candidate in candidates: | |
| paths_by_name.setdefault(candidate.name, []).append(candidate) | |
| patterns: dict[str, set[Path]] = {} | |
| def add_pattern(pattern: str, candidate: Path) -> None: | |
| patterns.setdefault(pattern, set()).add(candidate) | |
| for candidate in candidates: | |
| relative_path = candidate.relative_to(REPOSITORY_ROOT).as_posix() | |
| module_name = relative_path.removesuffix(".py").replace("/", ".") | |
| for pattern in (relative_path, f"./{relative_path}", module_name): | |
| add_pattern(pattern, candidate) | |
| if len(paths_by_name[candidate.name]) == 1: | |
| add_pattern(candidate.name, candidate) | |
| command = [ | |
| "rg", | |
| "--json", | |
| "--fixed-strings", | |
| "--hidden", | |
| "--glob", | |
| "!*.py", | |
| "--glob", | |
| "!.git/**", | |
| ] | |
| command.extend(argument for pattern in sorted(patterns) for argument in ("-e", pattern)) | |
| result = subprocess.run(command, cwd=REPOSITORY_ROOT, text=True, capture_output=True) | |
| if result.returncode not in (0, 1): | |
| raise RuntimeError(result.stderr.strip() or "rg failed while checking non-Python references") | |
| referenced: set[Path] = set() | |
| for line in result.stdout.splitlines(): | |
| event = json.loads(line) | |
| if event["type"] != "match": | |
| continue | |
| for submatch in event["data"]["submatches"]: | |
| referenced.update(patterns[submatch["match"]["text"]]) | |
| return referenced | |
| def is_within(path: Path, target: Path) -> bool: | |
| return path == target or target in path.parents | |
| def find_candidates(target: Path, excluded_paths: Iterable[Path]) -> list[Path]: | |
| graph = ruff_dependents() | |
| unreferenced = [ | |
| REPOSITORY_ROOT / module | |
| for module, dependents in graph.items() | |
| if not dependents and module.name != "__init__.py" | |
| ] | |
| non_scripts = [module for module in unreferenced if not is_script(module)] | |
| externally_referenced = non_python_references(non_scripts) | |
| return sorted( | |
| module | |
| for module in non_scripts | |
| if module not in externally_referenced | |
| and is_within(module, target) | |
| and not any(is_within(module, excluded_path) for excluded_path in excluded_paths) | |
| ) | |
| def main() -> int: | |
| args = parse_args() | |
| try: | |
| target = resolve_target(args.path) | |
| excluded_paths = [*pytest_testpaths(), *(resolve_target(path) for path in args.exclude)] | |
| candidates = find_candidates(target, excluded_paths) | |
| except (RuntimeError, ValueError) as error: | |
| print(f"error: {error}", file=sys.stderr) | |
| return 2 | |
| for candidate in candidates: | |
| print(candidate.relative_to(REPOSITORY_ROOT)) | |
| if args.delete: | |
| for candidate in candidates: | |
| candidate.unlink() | |
| print(f"deleted: {candidate.relative_to(REPOSITORY_ROOT)}", file=sys.stderr) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment