Skip to content

Instantly share code, notes, and snippets.

@mgaitan
Created August 3, 2026 15:15
Show Gist options
  • Select an option

  • Save mgaitan/e8767b5597fc42407a5757695595a74d to your computer and use it in GitHub Desktop.

Select an option

Save mgaitan/e8767b5597fc42407a5757695595a74d to your computer and use it in GitHub Desktop.
Fierro: detectar módulos Python candidatos a desuso
"""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