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.
@@ -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__(self, root_dir: str | Path) -> None:
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
- """Canonicalize package name per PEP 503 (lowercase, dashes instead of underscores)."""
117
+ """Normalize package name per PEP 503."""
91
118
  return re.sub(r"[-_.]+", "-", name).lower()
92
119
 
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()
120
+ def _extract_imports_from_file(self, file_path: Path) -> set[str]:
121
+ imports: set[str] = set()
97
122
  try:
98
- content = filepath.read_text(encoding="utf-8", errors="replace")
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 modules
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
- 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(".")
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
- for filename in files:
133
- if filename.endswith(".py"):
134
- imported_modules.update(
135
- self._extract_imports_from_file(Path(current_root) / filename)
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
- 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)
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_\-\.]+)", line)
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(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
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
- declared.update(self._read_pyproject_toml())
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, expected_dists: dict[str, str], declared: dict[str, str]
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 and canon not in self._KNOWN_DEV_TOOLS
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("Updated requirements.txt successfully")
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, fix: bool = False, prune_unused: bool = False
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
- existing_lines = req_file.read_text(encoding="utf-8").splitlines()
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_\-\.]+)", u))
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_\-\.]+)", stripped)
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(missing))
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)