ballpython 2.0.0__py3-none-any.whl → 2.0.2__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/py.typed +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/METADATA +13 -7
- ballpython-2.0.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +39 -2
- pycleaner/baseline.py +127 -0
- pycleaner/cache.py +282 -0
- pycleaner/cli.py +644 -43
- pycleaner/complexity_analyzer.py +16 -28
- pycleaner/config.py +8 -1
- pycleaner/dead_code_detector.py +275 -38
- pycleaner/dependency_auditor.py +175 -75
- pycleaner/discovery.py +206 -0
- pycleaner/explanations.py +323 -0
- pycleaner/frameworks/__init__.py +115 -0
- pycleaner/frameworks/plugins.py +290 -0
- pycleaner/modernizer.py +349 -0
- pycleaner/pipeline.py +208 -20
- pycleaner/py.typed +0 -0
- pycleaner/security_scanner.py +59 -38
- pycleaner/syntax_healer.py +188 -10
- pycleaner/taint_engine.py +8 -8
- pycleaner/test_generator.py +124 -35
- pycleaner/type_checker.py +29 -33
- pycleaner/verifier.py +495 -0
- ballpython-2.0.0.dist-info/RECORD +0 -24
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/WHEEL +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/top_level.txt +0 -0
pycleaner/dependency_auditor.py
CHANGED
|
@@ -10,13 +10,16 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import ast
|
|
12
12
|
import importlib.metadata
|
|
13
|
-
import os
|
|
14
13
|
import re
|
|
14
|
+
import shutil
|
|
15
15
|
import sys
|
|
16
|
+
from collections.abc import Sequence
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import ClassVar
|
|
19
20
|
|
|
21
|
+
from pycleaner.discovery import collect_project_python_files
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
@dataclass(slots=True)
|
|
22
25
|
class DependencyAuditReport:
|
|
@@ -57,11 +60,30 @@ class DependencyAuditor:
|
|
|
57
60
|
"OpenGL": "PyOpenGL",
|
|
58
61
|
"attr": "attrs",
|
|
59
62
|
"google": "protobuf",
|
|
63
|
+
# High-frequency PyPI packages
|
|
64
|
+
"nmap": "python-nmap",
|
|
65
|
+
"dns": "dnspython",
|
|
66
|
+
"telegram": "python-telegram-bot",
|
|
67
|
+
"discord": "discord.py",
|
|
68
|
+
"pkg_resources": "setuptools",
|
|
69
|
+
"setuptools": "setuptools",
|
|
70
|
+
"pydantic_core": "pydantic-core",
|
|
71
|
+
"multipart": "python-multipart",
|
|
72
|
+
"kafka": "kafka-python",
|
|
73
|
+
"smart_open": "smart-open",
|
|
74
|
+
"MySQLdb": "mysqlclient",
|
|
75
|
+
"psycopg2": "psycopg2-binary",
|
|
76
|
+
"redis": "redis",
|
|
77
|
+
"playwright": "playwright",
|
|
60
78
|
}
|
|
61
79
|
|
|
62
|
-
def __init__(
|
|
80
|
+
def __init__(
|
|
81
|
+
self, root_dir: str | Path, exclude_patterns: Sequence[str] = ()
|
|
82
|
+
) -> None:
|
|
63
83
|
self.root_dir = Path(root_dir).resolve()
|
|
84
|
+
self.exclude_patterns = tuple(exclude_patterns)
|
|
64
85
|
self.stdlib_names = set(sys.stdlib_module_names)
|
|
86
|
+
self._last_optional_packages: set[str] = set()
|
|
65
87
|
try:
|
|
66
88
|
self.dist_map = importlib.metadata.packages_distributions()
|
|
67
89
|
except AttributeError:
|
|
@@ -79,78 +101,106 @@ class DependencyAuditor:
|
|
|
79
101
|
"pylint",
|
|
80
102
|
"build",
|
|
81
103
|
"twine",
|
|
82
|
-
"pip",
|
|
83
104
|
"wheel",
|
|
84
105
|
"setuptools",
|
|
106
|
+
"pip",
|
|
107
|
+
"pre-commit",
|
|
108
|
+
"coverage",
|
|
109
|
+
"pytest-cov",
|
|
110
|
+
"tox",
|
|
111
|
+
"nox",
|
|
85
112
|
}
|
|
86
113
|
)
|
|
87
114
|
|
|
88
115
|
@staticmethod
|
|
89
116
|
def canonicalize_name(name: str) -> str:
|
|
90
|
-
"""
|
|
117
|
+
"""Normalize package name per PEP 503."""
|
|
91
118
|
return re.sub(r"[-_.]+", "-", name).lower()
|
|
92
119
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"""Extract root imported module names from a single Python file."""
|
|
96
|
-
modules: set[str] = set()
|
|
120
|
+
def _extract_imports_from_file(self, file_path: Path) -> set[str]:
|
|
121
|
+
imports: set[str] = set()
|
|
97
122
|
try:
|
|
98
|
-
|
|
99
|
-
tree = ast.parse(content, filename=str(filepath))
|
|
123
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8", errors="ignore"))
|
|
100
124
|
except SyntaxError:
|
|
101
|
-
return
|
|
125
|
+
return imports
|
|
102
126
|
|
|
103
127
|
for node in ast.walk(tree):
|
|
104
128
|
if isinstance(node, ast.Import):
|
|
105
129
|
for alias in node.names:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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(".")
|
|
130
|
+
root_pkg = alias.name.split(".")[0]
|
|
131
|
+
imports.add(root_pkg)
|
|
132
|
+
elif isinstance(node, ast.ImportFrom):
|
|
133
|
+
if node.level == 0 and node.module:
|
|
134
|
+
root_pkg = node.module.split(".")[0]
|
|
135
|
+
imports.add(root_pkg)
|
|
136
|
+
return imports
|
|
137
|
+
|
|
138
|
+
def scan_codebase_imports(
|
|
139
|
+
self, target_files: Sequence[Path] | None = None
|
|
140
|
+
) -> set[str]:
|
|
141
|
+
"""Scan Python files across the project or given targets for imported modules."""
|
|
142
|
+
if target_files is not None:
|
|
143
|
+
files_to_scan = [
|
|
144
|
+
f for f in target_files if f.is_file() and f.suffix == ".py"
|
|
131
145
|
]
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
else:
|
|
147
|
+
files_to_scan = collect_project_python_files(
|
|
148
|
+
self.root_dir, exclude_patterns=self.exclude_patterns
|
|
149
|
+
)
|
|
150
|
+
imported_modules: set[str] = set()
|
|
151
|
+
for py_file in files_to_scan:
|
|
152
|
+
imported_modules.update(self._extract_imports_from_file(py_file))
|
|
137
153
|
return imported_modules
|
|
138
154
|
|
|
139
155
|
def identify_local_modules(self) -> set[str]:
|
|
140
156
|
"""Identify local modules and packages belonging to the current project."""
|
|
141
157
|
local_mods: set[str] = set()
|
|
142
|
-
search_dirs = [self.root_dir]
|
|
143
158
|
|
|
159
|
+
# 1. Project root folder itself is a primary local package / namespace candidate
|
|
160
|
+
local_mods.add(self.root_dir.name)
|
|
161
|
+
local_mods.add(self.root_dir.stem)
|
|
162
|
+
|
|
163
|
+
# 2. Extract package name from pyproject.toml if present
|
|
164
|
+
pyproject_file = self.root_dir / "pyproject.toml"
|
|
165
|
+
if pyproject_file.is_file():
|
|
166
|
+
try:
|
|
167
|
+
import tomllib
|
|
168
|
+
except ImportError:
|
|
169
|
+
import tomli as tomllib # type: ignore
|
|
170
|
+
try:
|
|
171
|
+
data = tomllib.loads(
|
|
172
|
+
pyproject_file.read_text(encoding="utf-8", errors="ignore")
|
|
173
|
+
)
|
|
174
|
+
proj_name = data.get("project", {}).get("name") or data.get(
|
|
175
|
+
"tool", {}
|
|
176
|
+
).get("poetry", {}).get("name")
|
|
177
|
+
if proj_name:
|
|
178
|
+
local_mods.add(str(proj_name))
|
|
179
|
+
local_mods.add(str(proj_name).replace("-", "_"))
|
|
180
|
+
except Exception:
|
|
181
|
+
pass # Ignore invalid or non-standard pyproject.toml configuration
|
|
182
|
+
|
|
183
|
+
# 3. Discover local .py files and subpackages (including PEP 420 namespace packages)
|
|
184
|
+
search_dirs = [self.root_dir]
|
|
144
185
|
src_dir = self.root_dir / "src"
|
|
145
186
|
if src_dir.is_dir():
|
|
146
187
|
search_dirs.append(src_dir)
|
|
147
188
|
|
|
148
189
|
for sdir in search_dirs:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
190
|
+
try:
|
|
191
|
+
for item in sdir.iterdir():
|
|
192
|
+
if item.is_file() and item.suffix == ".py":
|
|
193
|
+
local_mods.add(item.stem)
|
|
194
|
+
elif item.is_dir() and not item.name.startswith((".", "__")):
|
|
195
|
+
# Standard package or PEP 420 namespace package with python files inside
|
|
196
|
+
if (item / "__init__.py").exists() or any(
|
|
197
|
+
sub.suffix == ".py"
|
|
198
|
+
for sub in item.iterdir()
|
|
199
|
+
if sub.is_file()
|
|
200
|
+
):
|
|
201
|
+
local_mods.add(item.name)
|
|
202
|
+
except OSError:
|
|
203
|
+
continue
|
|
154
204
|
|
|
155
205
|
return local_mods
|
|
156
206
|
|
|
@@ -171,22 +221,23 @@ class DependencyAuditor:
|
|
|
171
221
|
if not req_file.exists():
|
|
172
222
|
return declared
|
|
173
223
|
|
|
174
|
-
for line in req_file.read_text(encoding="utf-8").splitlines():
|
|
224
|
+
for line in req_file.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
175
225
|
line = line.strip()
|
|
176
226
|
if not line or line.startswith(("#", "-")):
|
|
177
227
|
continue
|
|
178
|
-
pkg_match = re.match(r"^([a-zA-Z0-9_
|
|
228
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_.-]+)", line)
|
|
179
229
|
if pkg_match:
|
|
180
230
|
pkg_name = pkg_match.group(1)
|
|
181
231
|
canon = self.canonicalize_name(pkg_name)
|
|
182
232
|
declared[canon] = line
|
|
183
233
|
return declared
|
|
184
234
|
|
|
185
|
-
def _read_pyproject_toml(self) -> dict[str, str]:
|
|
235
|
+
def _read_pyproject_toml(self) -> tuple[dict[str, str], set[str]]:
|
|
186
236
|
declared: dict[str, str] = {}
|
|
237
|
+
optional_pkgs: set[str] = set()
|
|
187
238
|
pyproject_file = self.root_dir / "pyproject.toml"
|
|
188
239
|
if not pyproject_file.exists():
|
|
189
|
-
return declared
|
|
240
|
+
return declared, optional_pkgs
|
|
190
241
|
|
|
191
242
|
try:
|
|
192
243
|
import tomllib
|
|
@@ -194,23 +245,45 @@ class DependencyAuditor:
|
|
|
194
245
|
import tomli as tomllib # type: ignore
|
|
195
246
|
|
|
196
247
|
try:
|
|
197
|
-
data = tomllib.loads(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
248
|
+
data = tomllib.loads(
|
|
249
|
+
pyproject_file.read_text(encoding="utf-8", errors="ignore")
|
|
250
|
+
)
|
|
251
|
+
project_data = data.get("project", {})
|
|
252
|
+
for dep in project_data.get("dependencies", []):
|
|
253
|
+
if isinstance(dep, str):
|
|
254
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_.-]+)", dep.strip())
|
|
255
|
+
if pkg_match:
|
|
256
|
+
canon = self.canonicalize_name(pkg_match.group(1))
|
|
257
|
+
declared[canon] = dep
|
|
258
|
+
|
|
259
|
+
for opt_list in project_data.get("optional-dependencies", {}).values():
|
|
260
|
+
if isinstance(opt_list, list):
|
|
261
|
+
for dep in opt_list:
|
|
262
|
+
if isinstance(dep, str):
|
|
263
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_.-]+)", dep.strip())
|
|
264
|
+
if pkg_match:
|
|
265
|
+
canon = self.canonicalize_name(pkg_match.group(1))
|
|
266
|
+
declared[canon] = dep
|
|
267
|
+
optional_pkgs.add(canon)
|
|
268
|
+
|
|
269
|
+
for grp_list in data.get("dependency-groups", {}).values():
|
|
270
|
+
if isinstance(grp_list, list):
|
|
271
|
+
for dep in grp_list:
|
|
272
|
+
if isinstance(dep, str):
|
|
273
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_.-]+)", dep.strip())
|
|
274
|
+
if pkg_match:
|
|
275
|
+
canon = self.canonicalize_name(pkg_match.group(1))
|
|
276
|
+
declared[canon] = dep
|
|
277
|
+
optional_pkgs.add(canon)
|
|
278
|
+
except Exception:
|
|
279
|
+
pass # Best-effort reading; continue if pyproject.toml is unparseable
|
|
280
|
+
return declared, optional_pkgs
|
|
209
281
|
|
|
210
282
|
def read_declared_dependencies(self) -> dict[str, str]:
|
|
211
283
|
"""Read declared dependencies from requirements.txt or pyproject.toml."""
|
|
212
284
|
declared = self._read_requirements_txt()
|
|
213
|
-
|
|
285
|
+
pyproj_declared, self._last_optional_packages = self._read_pyproject_toml()
|
|
286
|
+
declared.update(pyproj_declared)
|
|
214
287
|
return declared
|
|
215
288
|
|
|
216
289
|
def _resolve_expected_distributions(
|
|
@@ -224,17 +297,23 @@ class DependencyAuditor:
|
|
|
224
297
|
return expected_dists
|
|
225
298
|
|
|
226
299
|
def _find_missing_and_unused(
|
|
227
|
-
self,
|
|
300
|
+
self,
|
|
301
|
+
expected_dists: dict[str, str],
|
|
302
|
+
declared: dict[str, str],
|
|
303
|
+
optional_packages: set[str] | None = None,
|
|
228
304
|
) -> tuple[set[str], set[str]]:
|
|
229
305
|
missing_packages = {
|
|
230
306
|
orig_name
|
|
231
307
|
for canon, orig_name in expected_dists.items()
|
|
232
308
|
if canon not in declared
|
|
233
309
|
}
|
|
310
|
+
optional_set = optional_packages or set()
|
|
234
311
|
unused_packages = {
|
|
235
312
|
orig_line
|
|
236
313
|
for canon, orig_line in declared.items()
|
|
237
|
-
if canon not in expected_dists
|
|
314
|
+
if canon not in expected_dists
|
|
315
|
+
and canon not in self._KNOWN_DEV_TOOLS
|
|
316
|
+
and canon not in optional_set
|
|
238
317
|
}
|
|
239
318
|
return missing_packages, unused_packages
|
|
240
319
|
|
|
@@ -252,14 +331,19 @@ class DependencyAuditor:
|
|
|
252
331
|
f"Unused dependencies (declared but not imported): {', '.join(sorted(unused))}"
|
|
253
332
|
)
|
|
254
333
|
if fixed:
|
|
255
|
-
details.append(
|
|
334
|
+
details.append(
|
|
335
|
+
"Updated requirements.txt successfully (backup saved as requirements.txt.bak)"
|
|
336
|
+
)
|
|
256
337
|
return details
|
|
257
338
|
|
|
258
339
|
def audit(
|
|
259
|
-
self,
|
|
340
|
+
self,
|
|
341
|
+
fix: bool = False,
|
|
342
|
+
prune_unused: bool = False,
|
|
343
|
+
target_files: Sequence[Path] | None = None,
|
|
260
344
|
) -> DependencyAuditReport:
|
|
261
345
|
"""Audit dependencies and optionally update requirements.txt."""
|
|
262
|
-
all_imports = self.scan_codebase_imports()
|
|
346
|
+
all_imports = self.scan_codebase_imports(target_files=target_files)
|
|
263
347
|
local_mods = self.identify_local_modules()
|
|
264
348
|
|
|
265
349
|
third_party_mods = {
|
|
@@ -271,7 +355,7 @@ class DependencyAuditor:
|
|
|
271
355
|
declared = self.read_declared_dependencies()
|
|
272
356
|
|
|
273
357
|
missing_packages, unused_packages = self._find_missing_and_unused(
|
|
274
|
-
expected_dists, declared
|
|
358
|
+
expected_dists, declared, optional_packages=self._last_optional_packages
|
|
275
359
|
)
|
|
276
360
|
|
|
277
361
|
fixed_reqs = False
|
|
@@ -297,16 +381,32 @@ class DependencyAuditor:
|
|
|
297
381
|
def _update_requirements(
|
|
298
382
|
self, missing: set[str], unused_to_remove: set[str]
|
|
299
383
|
) -> bool:
|
|
300
|
-
"""Append missing dependencies and remove unused ones in requirements.txt."""
|
|
384
|
+
"""Append missing dependencies and remove unused ones in requirements.txt with safety checks."""
|
|
301
385
|
req_file = self.root_dir / "requirements.txt"
|
|
302
386
|
existing_lines: list[str] = []
|
|
303
387
|
if req_file.exists():
|
|
304
|
-
|
|
388
|
+
# Create a backup before making any changes
|
|
389
|
+
backup_file = req_file.with_suffix(".txt.bak")
|
|
390
|
+
try:
|
|
391
|
+
shutil.copy2(req_file, backup_file)
|
|
392
|
+
except OSError:
|
|
393
|
+
pass # Continue if backup creation fails
|
|
394
|
+
existing_lines = req_file.read_text(
|
|
395
|
+
encoding="utf-8", errors="ignore"
|
|
396
|
+
).splitlines()
|
|
305
397
|
|
|
306
398
|
remove_canons = {
|
|
307
399
|
self.canonicalize_name(m.group(1))
|
|
308
400
|
for u in unused_to_remove
|
|
309
|
-
if (m := re.match(r"^([a-zA-Z0-9_
|
|
401
|
+
if (m := re.match(r"^([a-zA-Z0-9_.-]+)", u))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# Filter out any local module from being appended to requirements.txt
|
|
405
|
+
local_canons = {
|
|
406
|
+
self.canonicalize_name(m) for m in self.identify_local_modules()
|
|
407
|
+
}
|
|
408
|
+
safe_missing = {
|
|
409
|
+
pkg for pkg in missing if self.canonicalize_name(pkg) not in local_canons
|
|
310
410
|
}
|
|
311
411
|
|
|
312
412
|
new_lines: list[str] = []
|
|
@@ -316,7 +416,7 @@ class DependencyAuditor:
|
|
|
316
416
|
new_lines.append(line)
|
|
317
417
|
continue
|
|
318
418
|
|
|
319
|
-
pkg_match = re.match(r"^([a-zA-Z0-9_
|
|
419
|
+
pkg_match = re.match(r"^([a-zA-Z0-9_.-]+)", stripped)
|
|
320
420
|
if pkg_match:
|
|
321
421
|
pkg_name = pkg_match.group(1)
|
|
322
422
|
canon = self.canonicalize_name(pkg_name)
|
|
@@ -325,7 +425,7 @@ class DependencyAuditor:
|
|
|
325
425
|
new_lines.append(line)
|
|
326
426
|
|
|
327
427
|
# Append missing packages
|
|
328
|
-
new_lines.extend(sorted(
|
|
428
|
+
new_lines.extend(sorted(safe_missing))
|
|
329
429
|
|
|
330
430
|
req_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
331
431
|
return True
|
pycleaner/discovery.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized file discovery, traversal, and security/safety filtering engine.
|
|
3
|
+
|
|
4
|
+
Enforces strict exclusion of virtual environments, version control roots,
|
|
5
|
+
cache artifacts, and sensitive files (.env, secrets, keys, lockfiles) across
|
|
6
|
+
all pycleaner analysis, healing, and formatting phases.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Directories that must NEVER be traversed or inspected under any circumstance
|
|
17
|
+
DEFAULT_IGNORED_DIRS: frozenset[str] = frozenset(
|
|
18
|
+
{
|
|
19
|
+
".git",
|
|
20
|
+
".svn",
|
|
21
|
+
".hg",
|
|
22
|
+
".venv",
|
|
23
|
+
"venv",
|
|
24
|
+
"env",
|
|
25
|
+
".env",
|
|
26
|
+
"ENV",
|
|
27
|
+
".tox",
|
|
28
|
+
".nox",
|
|
29
|
+
".direnv",
|
|
30
|
+
"site-packages",
|
|
31
|
+
"__pycache__",
|
|
32
|
+
".mypy_cache",
|
|
33
|
+
".ruff_cache",
|
|
34
|
+
".pytest_cache",
|
|
35
|
+
".pytest_temp",
|
|
36
|
+
".release-test",
|
|
37
|
+
"build",
|
|
38
|
+
"dist",
|
|
39
|
+
".eggs",
|
|
40
|
+
".cache",
|
|
41
|
+
".idea",
|
|
42
|
+
".vscode",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Patterns for sensitive, secret, or lock files that must NEVER be cleaned or formatted
|
|
47
|
+
PROTECTED_FILE_PATTERNS: tuple[str, ...] = (
|
|
48
|
+
".env*",
|
|
49
|
+
"*.env",
|
|
50
|
+
"*secret*",
|
|
51
|
+
"*credential*",
|
|
52
|
+
"*.pem",
|
|
53
|
+
"*.key",
|
|
54
|
+
"*.cert",
|
|
55
|
+
"*.crt",
|
|
56
|
+
"*.pfx",
|
|
57
|
+
"poetry.lock",
|
|
58
|
+
"Pipfile.lock",
|
|
59
|
+
"uv.lock",
|
|
60
|
+
"pdm.lock",
|
|
61
|
+
"package-lock.json",
|
|
62
|
+
"yarn.lock",
|
|
63
|
+
"pnpm-lock.yaml",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_ignored_directory(name: str) -> bool:
|
|
68
|
+
"""Check if a directory name should be excluded from file discovery."""
|
|
69
|
+
if name in DEFAULT_IGNORED_DIRS:
|
|
70
|
+
return True
|
|
71
|
+
if name.endswith(".egg-info"):
|
|
72
|
+
return True
|
|
73
|
+
if name.startswith(".") and name not in (".", ".."):
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_protected_file(path: Path | str) -> bool:
|
|
79
|
+
"""Check if a file is protected and must not be touched by formatting or cleaning."""
|
|
80
|
+
name = Path(path).name
|
|
81
|
+
for pattern in PROTECTED_FILE_PATTERNS:
|
|
82
|
+
if fnmatch.fnmatch(name, pattern):
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def find_project_root(path: Path | str | None = None) -> Path:
|
|
88
|
+
"""
|
|
89
|
+
Find the project root containing pyproject.toml, setup.py, requirements.txt, or .git.
|
|
90
|
+
Traverses upwards from the given path (or current working directory).
|
|
91
|
+
"""
|
|
92
|
+
start = Path(path).resolve() if path else Path.cwd()
|
|
93
|
+
if start.is_file():
|
|
94
|
+
start = start.parent
|
|
95
|
+
|
|
96
|
+
for candidate in [start, *start.parents]:
|
|
97
|
+
if (
|
|
98
|
+
(candidate / "pyproject.toml").is_file()
|
|
99
|
+
or (candidate / "setup.py").is_file()
|
|
100
|
+
or (candidate / "setup.cfg").is_file()
|
|
101
|
+
or (candidate / "requirements.txt").is_file()
|
|
102
|
+
or (candidate / ".git").is_dir()
|
|
103
|
+
):
|
|
104
|
+
return candidate
|
|
105
|
+
|
|
106
|
+
return start
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_gitignore_patterns(project_root: Path) -> list[str]:
|
|
110
|
+
"""Parse .gitignore rules from the project root if present."""
|
|
111
|
+
gitignore_file = project_root / ".gitignore"
|
|
112
|
+
if not gitignore_file.is_file():
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
patterns: list[str] = []
|
|
116
|
+
try:
|
|
117
|
+
for line in gitignore_file.read_text(
|
|
118
|
+
encoding="utf-8", errors="ignore"
|
|
119
|
+
).splitlines():
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line or line.startswith("#"):
|
|
122
|
+
continue
|
|
123
|
+
patterns.append(line)
|
|
124
|
+
except OSError:
|
|
125
|
+
return []
|
|
126
|
+
return patterns
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def matches_gitignore(rel_path: str, patterns: Sequence[str]) -> bool:
|
|
130
|
+
"""Check if a normalized relative POSIX path matches any gitignore pattern."""
|
|
131
|
+
normalized = Path(rel_path).as_posix()
|
|
132
|
+
for pat in patterns:
|
|
133
|
+
pat_clean = pat.rstrip("/")
|
|
134
|
+
if fnmatch.fnmatch(normalized, pat) or fnmatch.fnmatch(normalized, pat_clean):
|
|
135
|
+
return True
|
|
136
|
+
if fnmatch.fnmatch(normalized, f"*/{pat_clean}") or fnmatch.fnmatch(
|
|
137
|
+
normalized, f"*/{pat}"
|
|
138
|
+
):
|
|
139
|
+
return True
|
|
140
|
+
for part in normalized.split("/"):
|
|
141
|
+
if fnmatch.fnmatch(part, pat_clean):
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def collect_project_python_files(
|
|
147
|
+
root: Path | str,
|
|
148
|
+
exclude_patterns: Sequence[str] = (),
|
|
149
|
+
respect_gitignore: bool = True,
|
|
150
|
+
) -> list[Path]:
|
|
151
|
+
"""
|
|
152
|
+
Canonical file discovery engine.
|
|
153
|
+
|
|
154
|
+
Recursively scans the target root, filtering out all virtual environments,
|
|
155
|
+
caches, version-control folders, gitignored files, protected files, and
|
|
156
|
+
custom exclude patterns. Returns sorted, resolved absolute paths.
|
|
157
|
+
"""
|
|
158
|
+
target_root = Path(root).resolve()
|
|
159
|
+
if target_root.is_file():
|
|
160
|
+
if target_root.suffix == ".py" and not is_protected_file(target_root):
|
|
161
|
+
return [target_root]
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
gitignore_rules = load_gitignore_patterns(target_root) if respect_gitignore else []
|
|
165
|
+
python_files: list[Path] = []
|
|
166
|
+
|
|
167
|
+
for current_root, dirs, filenames in os.walk(target_root):
|
|
168
|
+
# Prune ignored directories in-place to prevent os.walk from recursing into them
|
|
169
|
+
dirs[:] = [
|
|
170
|
+
d
|
|
171
|
+
for d in dirs
|
|
172
|
+
if not is_ignored_directory(d)
|
|
173
|
+
and not (respect_gitignore and matches_gitignore(d, gitignore_rules))
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
for fname in filenames:
|
|
177
|
+
if not fname.endswith(".py"):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
file_path = Path(current_root) / fname
|
|
181
|
+
if is_protected_file(file_path):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
rel_path = str(file_path.relative_to(target_root))
|
|
186
|
+
except ValueError:
|
|
187
|
+
rel_path = fname
|
|
188
|
+
|
|
189
|
+
if respect_gitignore and matches_gitignore(rel_path, gitignore_rules):
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if exclude_patterns:
|
|
193
|
+
rel_posix = Path(rel_path).as_posix()
|
|
194
|
+
rel_parts = Path(rel_path).parts
|
|
195
|
+
if any(
|
|
196
|
+
fnmatch.fnmatch(rel_posix, pat)
|
|
197
|
+
or fnmatch.fnmatch(rel_posix, f"*/{pat}")
|
|
198
|
+
or fnmatch.fnmatch(fname, pat)
|
|
199
|
+
or pat.rstrip("/\\") in rel_parts
|
|
200
|
+
for pat in exclude_patterns
|
|
201
|
+
):
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
python_files.append(file_path.resolve())
|
|
205
|
+
|
|
206
|
+
return sorted(python_files)
|