scan_imports.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. # tools/scan_imports.py
  2. import argparse
  3. import ast
  4. import sys
  5. from pathlib import Path
  6. from collections import defaultdict
  7. # Маппинг модуль→PyPI-пакет
  8. MODULE_TO_PYPI = {
  9. # Научный стек
  10. "numpy": "numpy",
  11. "cv2": "opencv-python",
  12. "skimage": "scikit-image",
  13. "matplotlib": "matplotlib",
  14. "PIL": "pillow",
  15. "pillow": "pillow",
  16. "pydicom": "pydicom",
  17. # GUI
  18. "PyQt5": "PyQt5",
  19. # Утилиты
  20. "yaml": "PyYAML",
  21. "bs4": "beautifulsoup4",
  22. "lxml": "lxml",
  23. "dateutil": "python-dateutil",
  24. "dotenv": "python-dotenv",
  25. "yattag": "yattag",
  26. # Сеть
  27. "requests": "requests",
  28. "aiohttp": "aiohttp",
  29. }
  30. # Локальные пакеты по префиксам (дополняй при необходимости)
  31. PROJECT_LOCAL_PREFIXES = {"knee"}
  32. try:
  33. STDLIB = set(sys.stdlib_module_names) # Python 3.10+
  34. except Exception:
  35. STDLIB = set()
  36. STDLIB.update({
  37. "typing", "pathlib", "json", "re", "subprocess", "shutil", "itertools",
  38. "functools", "collections", "dataclasses", "asyncio", "concurrent",
  39. "logging", "argparse", "base64", "hashlib", "hmac", "uuid",
  40. "tempfile", "time", "datetime", "math", "statistics", "http", "urllib",
  41. "xml", "csv", "sqlite3", "queue", "threading", "multiprocessing",
  42. "enum", "inspect", "traceback", "glob", "zipfile", "tarfile",
  43. "importlib", "pkgutil", "venv",
  44. })
  45. IGNORED = {"__future__"}
  46. def top_level(name: str) -> str:
  47. return name.split(".")[0]
  48. def find_py_files(src_dir: Path):
  49. for p in src_dir.rglob("*.py"):
  50. # пропускаем .venv
  51. if ".venv" in p.parts:
  52. continue
  53. yield p
  54. def collect_imports(py_file: Path):
  55. try:
  56. tree = ast.parse(py_file.read_text(encoding="utf-8"))
  57. except Exception:
  58. return []
  59. mods = []
  60. for node in ast.walk(tree):
  61. if isinstance(node, ast.Import):
  62. for alias in node.names:
  63. mods.append(("abs", top_level(alias.name)))
  64. elif isinstance(node, ast.ImportFrom):
  65. # относительные импорты считаем локальными и игнорируем
  66. if getattr(node, "level", 0) and node.level > 0:
  67. continue
  68. if node.module:
  69. mods.append(("abs", top_level(node.module)))
  70. return mods
  71. def discover_local_top_levels(src_dir: Path):
  72. """Имена, которые существуют в src как top-level пакет/модуль."""
  73. local = set()
  74. # Папки-пакеты
  75. for pkg_init in src_dir.rglob("__init__.py"):
  76. try:
  77. rel = pkg_init.parent.relative_to(src_dir)
  78. except ValueError:
  79. continue
  80. if rel.parts:
  81. local.add(rel.parts[0])
  82. # Одиночные модули
  83. for mod in src_dir.glob("*.py"):
  84. local.add(mod.stem)
  85. return local
  86. def map_to_pypi(mods, local_names):
  87. result = defaultdict(int)
  88. for kind, m in mods:
  89. if m in IGNORED or m in STDLIB:
  90. continue
  91. if m in PROJECT_LOCAL_PREFIXES:
  92. continue
  93. if m in local_names:
  94. continue
  95. pkg = MODULE_TO_PYPI.get(m, m)
  96. result[pkg] += 1
  97. return result
  98. def merge_with_existing(out_path: Path, counts: dict):
  99. existing = []
  100. if out_path.exists():
  101. existing = [
  102. line.strip()
  103. for line in out_path.read_text(encoding="utf-8").splitlines()
  104. if line.strip() and not line.strip().startswith("#")
  105. ]
  106. existing_pkgs = {line.split("==")[0].split(">=")[0] for line in existing}
  107. lines = list(existing)
  108. for pkg in sorted(counts.keys()):
  109. if pkg not in existing_pkgs:
  110. lines.append(pkg)
  111. return "\n".join(lines) + "\n"
  112. def main():
  113. ap = argparse.ArgumentParser(description="Scan imports and update requirements.in")
  114. ap.add_argument("--src", default="src", help="Source directory to scan")
  115. ap.add_argument("--out", default="requirements.in", help="Output requirements.in path")
  116. ap.add_argument("--update", action="store_true", help="Update existing file instead of overwrite")
  117. args = ap.parse_args()
  118. src_dir = Path(args.src).resolve()
  119. out_path = Path(args.out).resolve()
  120. all_mods = []
  121. for py in find_py_files(src_dir):
  122. all_mods.extend(collect_imports(py))
  123. local_names = discover_local_top_levels(src_dir)
  124. counts = map_to_pypi(all_mods, local_names)
  125. if args.update and out_path.exists():
  126. content = merge_with_existing(out_path, counts)
  127. else:
  128. content = "\n".join(sorted(counts.keys())) + "\n"
  129. out_path.write_text(content, encoding="utf-8")
  130. print(f"[✓] requirements.in updated at {out_path}")
  131. if __name__ == "__main__":
  132. main()