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.
@@ -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