ballpython 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ballpython/__init__.py +9 -0
- ballpython/__main__.py +8 -0
- ballpython/cli.py +7 -0
- ballpython-2.0.0.dist-info/METADATA +322 -0
- ballpython-2.0.0.dist-info/RECORD +24 -0
- ballpython-2.0.0.dist-info/WHEEL +5 -0
- ballpython-2.0.0.dist-info/entry_points.txt +3 -0
- ballpython-2.0.0.dist-info/top_level.txt +2 -0
- pycleaner/__init__.py +53 -0
- pycleaner/__main__.py +8 -0
- pycleaner/cli.py +1548 -0
- pycleaner/complexity_analyzer.py +473 -0
- pycleaner/config.py +254 -0
- pycleaner/dead_code_detector.py +515 -0
- pycleaner/dependency_auditor.py +331 -0
- pycleaner/import_resolver.py +832 -0
- pycleaner/linter_formatter.py +590 -0
- pycleaner/pipeline.py +349 -0
- pycleaner/security_scanner.py +563 -0
- pycleaner/syntax_healer.py +577 -0
- pycleaner/taint_engine.py +720 -0
- pycleaner/test_generator.py +444 -0
- pycleaner/type_checker.py +989 -0
- pycleaner/typeshed_resolver.py +395 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Static dependency auditor for Python codebases.
|
|
3
|
+
|
|
4
|
+
Scans all Python files across a project, extracts third-party module imports,
|
|
5
|
+
maps import names to PyPI package distributions, inspects requirements.txt
|
|
6
|
+
and pyproject.toml, and detects missing or unused project dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import importlib.metadata
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class DependencyAuditReport:
|
|
23
|
+
"""Detailed report of dependency usage across a codebase."""
|
|
24
|
+
|
|
25
|
+
imported_modules: set[str] = field(default_factory=set)
|
|
26
|
+
third_party_modules: set[str] = field(default_factory=set)
|
|
27
|
+
required_packages: set[str] = field(default_factory=set)
|
|
28
|
+
missing_packages: set[str] = field(default_factory=set)
|
|
29
|
+
unused_packages: set[str] = field(default_factory=set)
|
|
30
|
+
fixed_requirements: bool = False
|
|
31
|
+
details: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DependencyAuditor:
|
|
35
|
+
"""Performs static dependency auditing and synchronization."""
|
|
36
|
+
|
|
37
|
+
# Static mapping for packages where import name differs from package name
|
|
38
|
+
_KNOWN_IMPORT_TO_DIST: ClassVar[dict[str, str]] = {
|
|
39
|
+
"yaml": "PyYAML",
|
|
40
|
+
"PIL": "pillow",
|
|
41
|
+
"cv2": "opencv-python",
|
|
42
|
+
"dateutil": "python-dateutil",
|
|
43
|
+
"bs4": "beautifulsoup4",
|
|
44
|
+
"dotenv": "python-dotenv",
|
|
45
|
+
"sklearn": "scikit-learn",
|
|
46
|
+
"git": "GitPython",
|
|
47
|
+
"fitz": "PyMuPDF",
|
|
48
|
+
"serial": "pyserial",
|
|
49
|
+
"magic": "python-magic",
|
|
50
|
+
"jose": "python-jose",
|
|
51
|
+
"docx": "python-docx",
|
|
52
|
+
"pptx": "python-pptx",
|
|
53
|
+
"jwt": "PyJWT",
|
|
54
|
+
"OpenSSL": "pyOpenSSL",
|
|
55
|
+
"websocket": "websocket-client",
|
|
56
|
+
"Bio": "biopython",
|
|
57
|
+
"OpenGL": "PyOpenGL",
|
|
58
|
+
"attr": "attrs",
|
|
59
|
+
"google": "protobuf",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def __init__(self, root_dir: str | Path) -> None:
|
|
63
|
+
self.root_dir = Path(root_dir).resolve()
|
|
64
|
+
self.stdlib_names = set(sys.stdlib_module_names)
|
|
65
|
+
try:
|
|
66
|
+
self.dist_map = importlib.metadata.packages_distributions()
|
|
67
|
+
except AttributeError:
|
|
68
|
+
self.dist_map = {}
|
|
69
|
+
|
|
70
|
+
_KNOWN_DEV_TOOLS: ClassVar[frozenset[str]] = frozenset(
|
|
71
|
+
{
|
|
72
|
+
"ruff",
|
|
73
|
+
"black",
|
|
74
|
+
"isort",
|
|
75
|
+
"pytest",
|
|
76
|
+
"mypy",
|
|
77
|
+
"flake8",
|
|
78
|
+
"autoflake",
|
|
79
|
+
"pylint",
|
|
80
|
+
"build",
|
|
81
|
+
"twine",
|
|
82
|
+
"pip",
|
|
83
|
+
"wheel",
|
|
84
|
+
"setuptools",
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def canonicalize_name(name: str) -> str:
|
|
90
|
+
"""Canonicalize package name per PEP 503 (lowercase, dashes instead of underscores)."""
|
|
91
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _extract_imports_from_file(filepath: Path) -> set[str]:
|
|
95
|
+
"""Extract root imported module names from a single Python file."""
|
|
96
|
+
modules: set[str] = set()
|
|
97
|
+
try:
|
|
98
|
+
content = filepath.read_text(encoding="utf-8", errors="replace")
|
|
99
|
+
tree = ast.parse(content, filename=str(filepath))
|
|
100
|
+
except SyntaxError:
|
|
101
|
+
return modules
|
|
102
|
+
|
|
103
|
+
for node in ast.walk(tree):
|
|
104
|
+
if isinstance(node, ast.Import):
|
|
105
|
+
for alias in node.names:
|
|
106
|
+
modules.add(alias.name.split(".")[0])
|
|
107
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
108
|
+
modules.add(node.module.split(".")[0])
|
|
109
|
+
return modules
|
|
110
|
+
|
|
111
|
+
def scan_codebase_imports(self) -> set[str]:
|
|
112
|
+
"""Traverse the project directory and extract all imported root module names."""
|
|
113
|
+
ignore_dirs = {
|
|
114
|
+
".git",
|
|
115
|
+
".venv",
|
|
116
|
+
"venv",
|
|
117
|
+
"env",
|
|
118
|
+
"__pycache__",
|
|
119
|
+
"build",
|
|
120
|
+
"dist",
|
|
121
|
+
".tox",
|
|
122
|
+
".mypy_cache",
|
|
123
|
+
".pytest_cache",
|
|
124
|
+
".ruff_cache",
|
|
125
|
+
"site-packages",
|
|
126
|
+
}
|
|
127
|
+
imported_modules: set[str] = set()
|
|
128
|
+
for current_root, dirs, files in os.walk(self.root_dir):
|
|
129
|
+
dirs[:] = [
|
|
130
|
+
d for d in dirs if d not in ignore_dirs and not d.startswith(".")
|
|
131
|
+
]
|
|
132
|
+
for filename in files:
|
|
133
|
+
if filename.endswith(".py"):
|
|
134
|
+
imported_modules.update(
|
|
135
|
+
self._extract_imports_from_file(Path(current_root) / filename)
|
|
136
|
+
)
|
|
137
|
+
return imported_modules
|
|
138
|
+
|
|
139
|
+
def identify_local_modules(self) -> set[str]:
|
|
140
|
+
"""Identify local modules and packages belonging to the current project."""
|
|
141
|
+
local_mods: set[str] = set()
|
|
142
|
+
search_dirs = [self.root_dir]
|
|
143
|
+
|
|
144
|
+
src_dir = self.root_dir / "src"
|
|
145
|
+
if src_dir.is_dir():
|
|
146
|
+
search_dirs.append(src_dir)
|
|
147
|
+
|
|
148
|
+
for sdir in search_dirs:
|
|
149
|
+
for item in sdir.iterdir():
|
|
150
|
+
if item.is_file() and item.suffix == ".py":
|
|
151
|
+
local_mods.add(item.stem)
|
|
152
|
+
elif item.is_dir() and (item / "__init__.py").exists():
|
|
153
|
+
local_mods.add(item.name)
|
|
154
|
+
|
|
155
|
+
return local_mods
|
|
156
|
+
|
|
157
|
+
def module_to_distribution(self, module_name: str) -> str:
|
|
158
|
+
"""Map a Python import module name to its PyPI distribution package name."""
|
|
159
|
+
if module_name in self._KNOWN_IMPORT_TO_DIST:
|
|
160
|
+
return self._KNOWN_IMPORT_TO_DIST[module_name]
|
|
161
|
+
|
|
162
|
+
dists = self.dist_map.get(module_name)
|
|
163
|
+
if dists:
|
|
164
|
+
return dists[0]
|
|
165
|
+
|
|
166
|
+
return module_name
|
|
167
|
+
|
|
168
|
+
def _read_requirements_txt(self) -> dict[str, str]:
|
|
169
|
+
declared: dict[str, str] = {}
|
|
170
|
+
req_file = self.root_dir / "requirements.txt"
|
|
171
|
+
if not req_file.exists():
|
|
172
|
+
return declared
|
|
173
|
+
|
|
174
|
+
for line in req_file.read_text(encoding="utf-8").splitlines():
|
|
175
|
+
line = line.strip()
|
|
176
|
+
if not line or line.startswith(("#", "-")):
|
|
177
|
+
continue
|
|
178
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_\-\.]+)", line)
|
|
179
|
+
if pkg_match:
|
|
180
|
+
pkg_name = pkg_match.group(1)
|
|
181
|
+
canon = self.canonicalize_name(pkg_name)
|
|
182
|
+
declared[canon] = line
|
|
183
|
+
return declared
|
|
184
|
+
|
|
185
|
+
def _read_pyproject_toml(self) -> dict[str, str]:
|
|
186
|
+
declared: dict[str, str] = {}
|
|
187
|
+
pyproject_file = self.root_dir / "pyproject.toml"
|
|
188
|
+
if not pyproject_file.exists():
|
|
189
|
+
return declared
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
import tomllib
|
|
193
|
+
except ImportError:
|
|
194
|
+
import tomli as tomllib # type: ignore
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
data = tomllib.loads(pyproject_file.read_text(encoding="utf-8"))
|
|
198
|
+
project_deps = data.get("project", {}).get("dependencies", [])
|
|
199
|
+
for dep in project_deps:
|
|
200
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_\-\.]+)", dep.strip())
|
|
201
|
+
if pkg_match:
|
|
202
|
+
pkg_name = pkg_match.group(1)
|
|
203
|
+
canon = self.canonicalize_name(pkg_name)
|
|
204
|
+
declared[canon] = dep
|
|
205
|
+
except tomllib.TOMLDecodeError:
|
|
206
|
+
# Fall back to requirements.txt if pyproject.toml is malformed
|
|
207
|
+
pass
|
|
208
|
+
return declared
|
|
209
|
+
|
|
210
|
+
def read_declared_dependencies(self) -> dict[str, str]:
|
|
211
|
+
"""Read declared dependencies from requirements.txt or pyproject.toml."""
|
|
212
|
+
declared = self._read_requirements_txt()
|
|
213
|
+
declared.update(self._read_pyproject_toml())
|
|
214
|
+
return declared
|
|
215
|
+
|
|
216
|
+
def _resolve_expected_distributions(
|
|
217
|
+
self, third_party_mods: set[str]
|
|
218
|
+
) -> dict[str, str]:
|
|
219
|
+
expected_dists: dict[str, str] = {}
|
|
220
|
+
for mod in third_party_mods:
|
|
221
|
+
dist_name = self.module_to_distribution(mod)
|
|
222
|
+
canon = self.canonicalize_name(dist_name)
|
|
223
|
+
expected_dists[canon] = dist_name
|
|
224
|
+
return expected_dists
|
|
225
|
+
|
|
226
|
+
def _find_missing_and_unused(
|
|
227
|
+
self, expected_dists: dict[str, str], declared: dict[str, str]
|
|
228
|
+
) -> tuple[set[str], set[str]]:
|
|
229
|
+
missing_packages = {
|
|
230
|
+
orig_name
|
|
231
|
+
for canon, orig_name in expected_dists.items()
|
|
232
|
+
if canon not in declared
|
|
233
|
+
}
|
|
234
|
+
unused_packages = {
|
|
235
|
+
orig_line
|
|
236
|
+
for canon, orig_line in declared.items()
|
|
237
|
+
if canon not in expected_dists and canon not in self._KNOWN_DEV_TOOLS
|
|
238
|
+
}
|
|
239
|
+
return missing_packages, unused_packages
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _build_audit_details(
|
|
243
|
+
missing: set[str], unused: set[str], fixed: bool
|
|
244
|
+
) -> list[str]:
|
|
245
|
+
details: list[str] = []
|
|
246
|
+
if missing:
|
|
247
|
+
details.append(
|
|
248
|
+
f"Missing dependencies (used in code but undeclared): {', '.join(sorted(missing))}"
|
|
249
|
+
)
|
|
250
|
+
if unused:
|
|
251
|
+
details.append(
|
|
252
|
+
f"Unused dependencies (declared but not imported): {', '.join(sorted(unused))}"
|
|
253
|
+
)
|
|
254
|
+
if fixed:
|
|
255
|
+
details.append("Updated requirements.txt successfully")
|
|
256
|
+
return details
|
|
257
|
+
|
|
258
|
+
def audit(
|
|
259
|
+
self, fix: bool = False, prune_unused: bool = False
|
|
260
|
+
) -> DependencyAuditReport:
|
|
261
|
+
"""Audit dependencies and optionally update requirements.txt."""
|
|
262
|
+
all_imports = self.scan_codebase_imports()
|
|
263
|
+
local_mods = self.identify_local_modules()
|
|
264
|
+
|
|
265
|
+
third_party_mods = {
|
|
266
|
+
m
|
|
267
|
+
for m in all_imports
|
|
268
|
+
if m not in self.stdlib_names and m not in local_mods and m != "__future__"
|
|
269
|
+
}
|
|
270
|
+
expected_dists = self._resolve_expected_distributions(third_party_mods)
|
|
271
|
+
declared = self.read_declared_dependencies()
|
|
272
|
+
|
|
273
|
+
missing_packages, unused_packages = self._find_missing_and_unused(
|
|
274
|
+
expected_dists, declared
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
fixed_reqs = False
|
|
278
|
+
if fix and (missing_packages or (prune_unused and unused_packages)):
|
|
279
|
+
fixed_reqs = self._update_requirements(
|
|
280
|
+
missing_packages, unused_packages if prune_unused else set()
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
details = self._build_audit_details(
|
|
284
|
+
missing_packages, unused_packages, fixed_reqs
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return DependencyAuditReport(
|
|
288
|
+
imported_modules=all_imports,
|
|
289
|
+
third_party_modules=third_party_mods,
|
|
290
|
+
required_packages=set(declared.keys()),
|
|
291
|
+
missing_packages=missing_packages,
|
|
292
|
+
unused_packages=unused_packages,
|
|
293
|
+
fixed_requirements=fixed_reqs,
|
|
294
|
+
details=details,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _update_requirements(
|
|
298
|
+
self, missing: set[str], unused_to_remove: set[str]
|
|
299
|
+
) -> bool:
|
|
300
|
+
"""Append missing dependencies and remove unused ones in requirements.txt."""
|
|
301
|
+
req_file = self.root_dir / "requirements.txt"
|
|
302
|
+
existing_lines: list[str] = []
|
|
303
|
+
if req_file.exists():
|
|
304
|
+
existing_lines = req_file.read_text(encoding="utf-8").splitlines()
|
|
305
|
+
|
|
306
|
+
remove_canons = {
|
|
307
|
+
self.canonicalize_name(m.group(1))
|
|
308
|
+
for u in unused_to_remove
|
|
309
|
+
if (m := re.match(r"^([a-zA-Z0-9_\-\.]+)", u))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
new_lines: list[str] = []
|
|
313
|
+
for line in existing_lines:
|
|
314
|
+
stripped = line.strip()
|
|
315
|
+
if not stripped or stripped.startswith(("#", "-")):
|
|
316
|
+
new_lines.append(line)
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_\-\.]+)", stripped)
|
|
320
|
+
if pkg_match:
|
|
321
|
+
pkg_name = pkg_match.group(1)
|
|
322
|
+
canon = self.canonicalize_name(pkg_name)
|
|
323
|
+
if canon in remove_canons:
|
|
324
|
+
continue
|
|
325
|
+
new_lines.append(line)
|
|
326
|
+
|
|
327
|
+
# Append missing packages
|
|
328
|
+
new_lines.extend(sorted(missing))
|
|
329
|
+
|
|
330
|
+
req_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
331
|
+
return True
|