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.
pycleaner/config.py ADDED
@@ -0,0 +1,254 @@
1
+ """
2
+ Configuration system for pycleaner.
3
+
4
+ Loads settings from pyproject.toml [tool.pycleaner] or .pycleaner.toml,
5
+ merges with CLI arguments and built-in defaults.
6
+ Exposes configuration as a frozen PyCleanerConfig dataclass.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ try:
16
+ import tomllib # Python 3.11+
17
+ except ModuleNotFoundError:
18
+ try:
19
+ import tomli as tomllib # type: ignore[import-not-found,no-redef]
20
+ except ModuleNotFoundError:
21
+ tomllib = None # type: ignore[assignment]
22
+
23
+
24
+ class ConfigError(ValueError):
25
+ """Raised when configuration file parsing or validation fails."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PyCleanerConfig:
30
+ """Resolved immutable configuration for a pycleaner run."""
31
+
32
+ # File targeting
33
+ exclude: list[str] = field(
34
+ default_factory=lambda: [
35
+ "migrations/",
36
+ "generated/",
37
+ "*_pb2.py",
38
+ "*_pb2_grpc.py",
39
+ ]
40
+ )
41
+ include: list[str] = field(default_factory=list)
42
+
43
+ # Syntax healing
44
+ fix_py2_syntax: bool = True
45
+ fix_conditional_assignments: bool = True
46
+
47
+ # Import resolution
48
+ custom_import_map: dict[str, str] = field(default_factory=dict)
49
+ auto_add_future_annotations: bool = False
50
+
51
+ # Linting & formatting
52
+ line_length: int = 88
53
+ select_rules: str = "F401,F841,I,UP,E,W"
54
+
55
+ # Dead code
56
+ ignore_decorators: list[str] = field(
57
+ default_factory=lambda: [
58
+ "@app.route",
59
+ "@pytest.fixture",
60
+ "@override",
61
+ ]
62
+ )
63
+ ignore_names: list[str] = field(
64
+ default_factory=lambda: [
65
+ "_*",
66
+ "test_*",
67
+ ]
68
+ )
69
+
70
+ # Security
71
+ security_severity_threshold: str = "LOW"
72
+ ignore_security_rules: list[str] = field(default_factory=list)
73
+
74
+ # Type Verification
75
+ strict_types: bool = False
76
+
77
+ # Complexity thresholds
78
+ max_cyclomatic_complexity: int = 10
79
+ max_cognitive_complexity: int = 15
80
+ max_function_length: int = 50
81
+ max_arguments: int = 5
82
+
83
+ # Behavior
84
+ backup: bool = True
85
+ parallel: bool = False
86
+ max_workers: int = 4
87
+
88
+
89
+ _KEY_MAP: dict[str, str] = {
90
+ "exclude": "exclude",
91
+ "include": "include",
92
+ "fix-py2-syntax": "fix_py2_syntax",
93
+ "fix_py2_syntax": "fix_py2_syntax",
94
+ "fix-conditional-assignments": "fix_conditional_assignments",
95
+ "fix_conditional_assignments": "fix_conditional_assignments",
96
+ "custom-import-map": "custom_import_map",
97
+ "custom_import_map": "custom_import_map",
98
+ "auto-add-future-annotations": "auto_add_future_annotations",
99
+ "auto_add_future_annotations": "auto_add_future_annotations",
100
+ "line-length": "line_length",
101
+ "line_length": "line_length",
102
+ "select-rules": "select_rules",
103
+ "select_rules": "select_rules",
104
+ "ignore-decorators": "ignore_decorators",
105
+ "ignore_decorators": "ignore_decorators",
106
+ "ignore-names": "ignore_names",
107
+ "ignore_names": "ignore_names",
108
+ "security-severity-threshold": "security_severity_threshold",
109
+ "security_severity_threshold": "security_severity_threshold",
110
+ "ignore-security-rules": "ignore_security_rules",
111
+ "ignore_security_rules": "ignore_security_rules",
112
+ "strict-types": "strict_types",
113
+ "strict_types": "strict_types",
114
+ "max-cyclomatic-complexity": "max_cyclomatic_complexity",
115
+ "max_cyclomatic_complexity": "max_cyclomatic_complexity",
116
+ "max-cognitive-complexity": "max_cognitive_complexity",
117
+ "max_cognitive_complexity": "max_cognitive_complexity",
118
+ "max-function-length": "max_function_length",
119
+ "max_function_length": "max_function_length",
120
+ "max-arguments": "max_arguments",
121
+ "max_arguments": "max_arguments",
122
+ "backup": "backup",
123
+ "parallel": "parallel",
124
+ "max-workers": "max_workers",
125
+ "max_workers": "max_workers",
126
+ }
127
+
128
+
129
+ def _resolve_file_config(
130
+ project_root: Path | str | None,
131
+ explicit_config_file: Path | str | None,
132
+ ) -> dict[str, Any]:
133
+ if explicit_config_file is not None:
134
+ config_path = Path(explicit_config_file).resolve()
135
+ if not config_path.is_file():
136
+ raise ConfigError(f"Config file not found: {config_path}")
137
+ if config_path.name == "pyproject.toml":
138
+ return _load_from_pyproject(config_path.parent) or {}
139
+ return _load_from_toml_file(config_path) or {}
140
+
141
+ if project_root is not None:
142
+ root = Path(project_root).resolve()
143
+ if not root.is_dir():
144
+ root = root.parent
145
+ return _load_from_pyproject(root) or _load_from_dotfile(root) or {}
146
+
147
+ return {}
148
+
149
+
150
+ def load_config(
151
+ project_root: Path | str | None = None,
152
+ cli_overrides: dict[str, Any] | None = None,
153
+ explicit_config_file: Path | str | None = None,
154
+ ) -> PyCleanerConfig:
155
+ """Load and merge configuration from file and CLI overrides."""
156
+ file_config = _resolve_file_config(project_root, explicit_config_file)
157
+ merged: dict[str, Any] = {
158
+ "exclude": ["migrations/", "generated/", "*_pb2.py", "*_pb2_grpc.py"],
159
+ "include": [],
160
+ "fix_py2_syntax": True,
161
+ "fix_conditional_assignments": True,
162
+ "custom_import_map": {},
163
+ "auto_add_future_annotations": False,
164
+ "line_length": 88,
165
+ "select_rules": "F401,F841,I,UP,E,W",
166
+ "ignore_decorators": ["@app.route", "@pytest.fixture", "@override"],
167
+ "ignore_names": ["_*", "test_*"],
168
+ "security_severity_threshold": "LOW",
169
+ "ignore_security_rules": [],
170
+ "strict_types": False,
171
+ "max_cyclomatic_complexity": 10,
172
+ "max_cognitive_complexity": 15,
173
+ "max_function_length": 50,
174
+ "max_arguments": 5,
175
+ "backup": True,
176
+ "parallel": False,
177
+ "max_workers": 4,
178
+ }
179
+
180
+ if file_config:
181
+ _apply_dict(merged, file_config)
182
+ if cli_overrides:
183
+ _apply_dict(merged, cli_overrides)
184
+ return PyCleanerConfig(**merged)
185
+
186
+
187
+ def _load_from_pyproject(root: Path) -> dict[str, Any] | None:
188
+ """Attempt to load [tool.ballpython] or [tool.pycleaner] from pyproject.toml."""
189
+ pyproject = root / "pyproject.toml"
190
+ if not pyproject.is_file():
191
+ return None
192
+
193
+ if tomllib is None:
194
+ raise ConfigError("tomllib or tomli is required to parse pyproject.toml")
195
+
196
+ try:
197
+ with open(pyproject, "rb") as f:
198
+ data = tomllib.load(f)
199
+ tool_section = data.get("tool", {})
200
+ return tool_section.get("ballpython") or tool_section.get("pycleaner")
201
+ except Exception as err:
202
+ raise ConfigError(f"Failed to parse pyproject.toml: {err}") from err
203
+
204
+
205
+ def _load_from_dotfile(root: Path) -> dict[str, Any] | None:
206
+ """Attempt to load .ballpython.toml or .pycleaner.toml."""
207
+ for name in (".ballpython.toml", ".pycleaner.toml"):
208
+ dotfile = root / name
209
+ if dotfile.is_file():
210
+ return _load_from_toml_file(dotfile)
211
+ return None
212
+
213
+
214
+ def _load_from_toml_file(path: Path) -> dict[str, Any] | None:
215
+ """Load a flat pycleaner-format TOML file (top-level keys, no [tool.pycleaner] wrapper)."""
216
+ if tomllib is None:
217
+ raise ConfigError(f"tomllib or tomli is required to parse {path}")
218
+
219
+ try:
220
+ with open(path, "rb") as f:
221
+ return tomllib.load(f)
222
+ except Exception as err:
223
+ raise ConfigError(f"Failed to parse {path}: {err}") from err
224
+
225
+
226
+ _EXPECTED_TYPES: dict[type, tuple[type, str]] = {
227
+ bool: (bool, "boolean"),
228
+ str: (str, "string"),
229
+ list: (list, "list"),
230
+ dict: (dict, "dict"),
231
+ }
232
+
233
+
234
+ def _validate_value_type(key: str, current: Any, value: Any) -> None:
235
+ if type(current) is int:
236
+ if type(value) is not int:
237
+ raise ConfigError(f"Invalid integer value for '{key}': {value!r}")
238
+ return
239
+
240
+ expected = _EXPECTED_TYPES.get(type(current))
241
+ if expected is not None:
242
+ expected_cls, type_name = expected
243
+ if not isinstance(value, expected_cls):
244
+ raise ConfigError(f"Invalid {type_name} value for '{key}': {value!r}")
245
+
246
+
247
+ def _apply_dict(target: dict[str, Any], data: dict[str, Any]) -> None:
248
+ """Apply a dictionary of settings into target dict, normalizing key names and validating types."""
249
+ for key, value in data.items():
250
+ attr = _KEY_MAP.get(key)
251
+ if attr is None or attr not in target:
252
+ raise ConfigError(f"Unknown configuration key: '{key}'")
253
+ _validate_value_type(key, target[attr], value)
254
+ target[attr] = value