gwc-pybundle 2.1.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.
Potentially problematic release.
This version of gwc-pybundle might be problematic. Click here for more details.
- gwc_pybundle-2.1.2.dist-info/METADATA +903 -0
- gwc_pybundle-2.1.2.dist-info/RECORD +82 -0
- gwc_pybundle-2.1.2.dist-info/WHEEL +5 -0
- gwc_pybundle-2.1.2.dist-info/entry_points.txt +2 -0
- gwc_pybundle-2.1.2.dist-info/licenses/LICENSE.md +25 -0
- gwc_pybundle-2.1.2.dist-info/top_level.txt +1 -0
- pybundle/__init__.py +0 -0
- pybundle/__main__.py +4 -0
- pybundle/cli.py +546 -0
- pybundle/context.py +404 -0
- pybundle/doctor.py +148 -0
- pybundle/filters.py +228 -0
- pybundle/manifest.py +77 -0
- pybundle/packaging.py +45 -0
- pybundle/policy.py +132 -0
- pybundle/profiles.py +454 -0
- pybundle/roadmap_model.py +42 -0
- pybundle/roadmap_scan.py +328 -0
- pybundle/root_detect.py +14 -0
- pybundle/runner.py +180 -0
- pybundle/steps/__init__.py +26 -0
- pybundle/steps/ai_context.py +791 -0
- pybundle/steps/api_docs.py +219 -0
- pybundle/steps/asyncio_analysis.py +358 -0
- pybundle/steps/bandit.py +72 -0
- pybundle/steps/base.py +20 -0
- pybundle/steps/blocking_call_detection.py +291 -0
- pybundle/steps/call_graph.py +219 -0
- pybundle/steps/compileall.py +76 -0
- pybundle/steps/config_docs.py +319 -0
- pybundle/steps/config_validation.py +302 -0
- pybundle/steps/container_image.py +294 -0
- pybundle/steps/context_expand.py +272 -0
- pybundle/steps/copy_pack.py +293 -0
- pybundle/steps/coverage.py +101 -0
- pybundle/steps/cprofile_step.py +166 -0
- pybundle/steps/dependency_sizes.py +136 -0
- pybundle/steps/django_checks.py +214 -0
- pybundle/steps/dockerfile_lint.py +282 -0
- pybundle/steps/dockerignore.py +311 -0
- pybundle/steps/duplication.py +103 -0
- pybundle/steps/env_completeness.py +269 -0
- pybundle/steps/env_var_usage.py +253 -0
- pybundle/steps/error_refs.py +204 -0
- pybundle/steps/event_loop_patterns.py +280 -0
- pybundle/steps/exception_patterns.py +190 -0
- pybundle/steps/fastapi_integration.py +250 -0
- pybundle/steps/flask_debugging.py +312 -0
- pybundle/steps/git_analytics.py +315 -0
- pybundle/steps/handoff_md.py +176 -0
- pybundle/steps/import_time.py +175 -0
- pybundle/steps/interrogate.py +106 -0
- pybundle/steps/license_scan.py +96 -0
- pybundle/steps/line_profiler.py +117 -0
- pybundle/steps/link_validation.py +287 -0
- pybundle/steps/logging_analysis.py +233 -0
- pybundle/steps/memory_profile.py +176 -0
- pybundle/steps/migration_history.py +336 -0
- pybundle/steps/mutation_testing.py +141 -0
- pybundle/steps/mypy.py +103 -0
- pybundle/steps/orm_optimization.py +316 -0
- pybundle/steps/pip_audit.py +45 -0
- pybundle/steps/pipdeptree.py +62 -0
- pybundle/steps/pylance.py +562 -0
- pybundle/steps/pytest.py +66 -0
- pybundle/steps/query_pattern_analysis.py +334 -0
- pybundle/steps/radon.py +161 -0
- pybundle/steps/repro_md.py +161 -0
- pybundle/steps/rg_scans.py +78 -0
- pybundle/steps/roadmap.py +153 -0
- pybundle/steps/ruff.py +117 -0
- pybundle/steps/secrets_detection.py +235 -0
- pybundle/steps/security_headers.py +309 -0
- pybundle/steps/shell.py +74 -0
- pybundle/steps/slow_tests.py +178 -0
- pybundle/steps/sqlalchemy_validation.py +269 -0
- pybundle/steps/test_flakiness.py +184 -0
- pybundle/steps/tree.py +116 -0
- pybundle/steps/type_coverage.py +277 -0
- pybundle/steps/unused_deps.py +211 -0
- pybundle/steps/vulture.py +167 -0
- pybundle/tools.py +63 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Configuration documentation step.
|
|
2
|
+
|
|
3
|
+
Extracts and documents environment variables and configuration options
|
|
4
|
+
from Python source code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Tuple
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .base import StepResult
|
|
15
|
+
from ..context import BundleContext
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigVarExtractor(ast.NodeVisitor):
|
|
19
|
+
"""AST visitor for extracting environment variable accesses."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, filepath: str) -> None:
|
|
22
|
+
self.filepath = filepath
|
|
23
|
+
self.env_vars: List[Tuple[int, str, str]] = [] # (line, var_name, default)
|
|
24
|
+
|
|
25
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
26
|
+
"""Visit function calls looking for os.getenv, os.environ.get, etc."""
|
|
27
|
+
# Check for os.getenv(var, default) or os.environ.get(var, default)
|
|
28
|
+
if isinstance(node.func, ast.Attribute):
|
|
29
|
+
if node.func.attr in ("getenv", "get"):
|
|
30
|
+
# Check if it's os.getenv or os.environ.get
|
|
31
|
+
if isinstance(node.func.value, ast.Name):
|
|
32
|
+
if node.func.value.id == "os":
|
|
33
|
+
self._extract_env_var(node)
|
|
34
|
+
elif isinstance(node.func.value, ast.Attribute):
|
|
35
|
+
if (
|
|
36
|
+
node.func.value.attr == "environ"
|
|
37
|
+
and isinstance(node.func.value.value, ast.Name)
|
|
38
|
+
and node.func.value.value.id == "os"
|
|
39
|
+
):
|
|
40
|
+
self._extract_env_var(node)
|
|
41
|
+
|
|
42
|
+
self.generic_visit(node)
|
|
43
|
+
|
|
44
|
+
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
45
|
+
"""Visit subscript operations like os.environ['VAR']."""
|
|
46
|
+
if isinstance(node.value, ast.Attribute):
|
|
47
|
+
if (
|
|
48
|
+
node.value.attr == "environ"
|
|
49
|
+
and isinstance(node.value.value, ast.Name)
|
|
50
|
+
and node.value.value.id == "os"
|
|
51
|
+
):
|
|
52
|
+
if isinstance(node.slice, ast.Constant):
|
|
53
|
+
var_name = node.slice.value
|
|
54
|
+
if isinstance(var_name, str):
|
|
55
|
+
self.env_vars.append((node.lineno, var_name, ""))
|
|
56
|
+
|
|
57
|
+
self.generic_visit(node)
|
|
58
|
+
|
|
59
|
+
def _extract_env_var(self, node: ast.Call):
|
|
60
|
+
"""Extract environment variable from getenv/get call."""
|
|
61
|
+
if len(node.args) >= 1:
|
|
62
|
+
# First argument is the variable name
|
|
63
|
+
if isinstance(node.args[0], ast.Constant):
|
|
64
|
+
var_name = node.args[0].value
|
|
65
|
+
if isinstance(var_name, str):
|
|
66
|
+
# Check for default value
|
|
67
|
+
default = ""
|
|
68
|
+
if len(node.args) >= 2:
|
|
69
|
+
if isinstance(node.args[1], ast.Constant):
|
|
70
|
+
default = repr(node.args[1].value)
|
|
71
|
+
else:
|
|
72
|
+
default = "<expression>"
|
|
73
|
+
|
|
74
|
+
self.env_vars.append((node.lineno, var_name, default))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ConfigDocumentationStep:
|
|
79
|
+
"""Step that documents configuration variables."""
|
|
80
|
+
|
|
81
|
+
name: str = "config-docs"
|
|
82
|
+
outfile: str = "meta/83_config_vars.txt"
|
|
83
|
+
|
|
84
|
+
def run(self, context: BundleContext) -> StepResult:
|
|
85
|
+
"""Extract and document configuration variables."""
|
|
86
|
+
start = time.time()
|
|
87
|
+
|
|
88
|
+
# Find all Python files
|
|
89
|
+
python_files = self._find_python_files(context.root)
|
|
90
|
+
|
|
91
|
+
if not python_files:
|
|
92
|
+
elapsed = time.time() - start
|
|
93
|
+
note = "No Python files found"
|
|
94
|
+
return StepResult(self.name, "SKIP", int(elapsed), note)
|
|
95
|
+
|
|
96
|
+
# Extract environment variables from code
|
|
97
|
+
env_vars: List[Tuple[str, int, str, str]] = self._extract_env_vars(
|
|
98
|
+
python_files, context.root
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Look for config files
|
|
102
|
+
config_files = self._find_config_files(context.root)
|
|
103
|
+
|
|
104
|
+
# Parse config file patterns
|
|
105
|
+
config_patterns = self._extract_config_patterns(config_files, context.root)
|
|
106
|
+
|
|
107
|
+
elapsed = int(time.time() - start)
|
|
108
|
+
|
|
109
|
+
# Write report
|
|
110
|
+
log_path = context.workdir / self.outfile
|
|
111
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
with open(log_path, "w") as f:
|
|
113
|
+
f.write("=" * 80 + "\n")
|
|
114
|
+
f.write("CONFIGURATION DOCUMENTATION\n")
|
|
115
|
+
f.write("=" * 80 + "\n\n")
|
|
116
|
+
|
|
117
|
+
# Environment variables section
|
|
118
|
+
if env_vars:
|
|
119
|
+
f.write("Environment Variables Used:\n")
|
|
120
|
+
f.write("-" * 80 + "\n\n")
|
|
121
|
+
|
|
122
|
+
# Group by variable name
|
|
123
|
+
vars_grouped: Dict[str, List[Tuple[str, int, str]]] = {}
|
|
124
|
+
for filepath, lineno, var_name, default in env_vars:
|
|
125
|
+
if var_name not in vars_grouped:
|
|
126
|
+
vars_grouped[var_name] = []
|
|
127
|
+
vars_grouped[var_name].append((filepath, lineno, default))
|
|
128
|
+
|
|
129
|
+
# Sort by variable name
|
|
130
|
+
for var_name in sorted(vars_grouped.keys()):
|
|
131
|
+
locations = vars_grouped[var_name]
|
|
132
|
+
f.write(f"{var_name}\n")
|
|
133
|
+
|
|
134
|
+
# Show default if consistent
|
|
135
|
+
defaults = [d for _, _, d in locations if d is not None]
|
|
136
|
+
if defaults:
|
|
137
|
+
if len(set(defaults)) == 1:
|
|
138
|
+
f.write(f" Default: {defaults[0]}\n")
|
|
139
|
+
else:
|
|
140
|
+
f.write(f" Defaults: {', '.join(set(defaults))}\n")
|
|
141
|
+
|
|
142
|
+
f.write(" Used in:\n")
|
|
143
|
+
for filepath, lineno, _ in sorted(
|
|
144
|
+
locations, key=lambda x: (x[0], x[1])
|
|
145
|
+
):
|
|
146
|
+
f.write(f" {filepath}:{lineno}\n")
|
|
147
|
+
|
|
148
|
+
f.write("\n")
|
|
149
|
+
|
|
150
|
+
f.write(f"Total: {len(vars_grouped)} unique environment variables\n\n")
|
|
151
|
+
else:
|
|
152
|
+
f.write("No environment variables found in source code.\n\n")
|
|
153
|
+
|
|
154
|
+
# Configuration files section
|
|
155
|
+
if config_files:
|
|
156
|
+
f.write("Configuration Files:\n")
|
|
157
|
+
f.write("-" * 80 + "\n")
|
|
158
|
+
for config_file in sorted(config_files):
|
|
159
|
+
f.write(f" {config_file}\n")
|
|
160
|
+
f.write("\n")
|
|
161
|
+
|
|
162
|
+
# Configuration patterns section
|
|
163
|
+
if config_patterns:
|
|
164
|
+
f.write("Configuration Patterns Detected:\n")
|
|
165
|
+
f.write("-" * 80 + "\n")
|
|
166
|
+
for pattern, pattern_locations in sorted(config_patterns.items()):
|
|
167
|
+
f.write(f"\n{pattern}\n")
|
|
168
|
+
for filepath, lineno in pattern_locations[:5]: # Limit to 5
|
|
169
|
+
f.write(f" {filepath}:{lineno}\n")
|
|
170
|
+
if len(pattern_locations) > 5:
|
|
171
|
+
f.write(f" ... and {len(pattern_locations) - 5} more\n")
|
|
172
|
+
f.write("\n")
|
|
173
|
+
|
|
174
|
+
f.write("=" * 80 + "\n")
|
|
175
|
+
f.write("Configuration documentation complete\n")
|
|
176
|
+
f.write("=" * 80 + "\n")
|
|
177
|
+
|
|
178
|
+
# Determine status
|
|
179
|
+
total_items = len(env_vars) + len(config_patterns)
|
|
180
|
+
if total_items > 0:
|
|
181
|
+
status = "OK"
|
|
182
|
+
note = f"Found {len(set(v[2] for v in env_vars))} env vars, {len(config_files)} config files"
|
|
183
|
+
else:
|
|
184
|
+
status = "SKIP"
|
|
185
|
+
note = "No configuration found"
|
|
186
|
+
|
|
187
|
+
return StepResult(self.name, status, elapsed, note)
|
|
188
|
+
|
|
189
|
+
def _find_python_files(self, root: Path) -> List[Path]:
|
|
190
|
+
"""Find all Python source files."""
|
|
191
|
+
python_files = []
|
|
192
|
+
exclude_dirs = {
|
|
193
|
+
"__pycache__",
|
|
194
|
+
".git",
|
|
195
|
+
".tox",
|
|
196
|
+
"venv",
|
|
197
|
+
"env",
|
|
198
|
+
".venv",
|
|
199
|
+
".env",
|
|
200
|
+
"node_modules",
|
|
201
|
+
"artifacts",
|
|
202
|
+
"build",
|
|
203
|
+
"dist",
|
|
204
|
+
".pytest_cache",
|
|
205
|
+
".mypy_cache",
|
|
206
|
+
".ruff_cache",
|
|
207
|
+
".pybundle-venv", # pybundle's venv
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for path in root.rglob("*.py"):
|
|
211
|
+
if any(part in exclude_dirs for part in path.parts):
|
|
212
|
+
continue
|
|
213
|
+
python_files.append(path)
|
|
214
|
+
|
|
215
|
+
return python_files
|
|
216
|
+
|
|
217
|
+
def _extract_env_vars(
|
|
218
|
+
self, python_files: List[Path], root: Path
|
|
219
|
+
) -> List[Tuple[str, int, str, str]]:
|
|
220
|
+
"""Extract environment variables from Python files.
|
|
221
|
+
|
|
222
|
+
Returns list of (filepath, lineno, var_name, default).
|
|
223
|
+
"""
|
|
224
|
+
env_vars = []
|
|
225
|
+
|
|
226
|
+
for filepath in python_files:
|
|
227
|
+
try:
|
|
228
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
229
|
+
source = f.read()
|
|
230
|
+
|
|
231
|
+
tree = ast.parse(source, filename=str(filepath))
|
|
232
|
+
extractor = ConfigVarExtractor(str(filepath))
|
|
233
|
+
extractor.visit(tree)
|
|
234
|
+
|
|
235
|
+
rel_path = str(filepath.relative_to(root))
|
|
236
|
+
for lineno, var_name, default in extractor.env_vars:
|
|
237
|
+
env_vars.append((rel_path, lineno, var_name, default))
|
|
238
|
+
|
|
239
|
+
except (SyntaxError, Exception):
|
|
240
|
+
# Skip files that can't be parsed
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
return env_vars
|
|
244
|
+
|
|
245
|
+
def _find_config_files(self, root: Path) -> List[str]:
|
|
246
|
+
"""Find configuration files in the project."""
|
|
247
|
+
config_patterns = [
|
|
248
|
+
"*.ini",
|
|
249
|
+
"*.cfg",
|
|
250
|
+
"*.conf",
|
|
251
|
+
"*.yaml",
|
|
252
|
+
"*.yml",
|
|
253
|
+
"*.toml",
|
|
254
|
+
".env*",
|
|
255
|
+
"config.*",
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
config_files = []
|
|
259
|
+
exclude_dirs = {
|
|
260
|
+
"__pycache__",
|
|
261
|
+
".git",
|
|
262
|
+
".tox",
|
|
263
|
+
"venv",
|
|
264
|
+
"env",
|
|
265
|
+
".venv",
|
|
266
|
+
"node_modules",
|
|
267
|
+
"artifacts",
|
|
268
|
+
"build",
|
|
269
|
+
"dist",
|
|
270
|
+
".pybundle-venv", # pybundle's venv
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for pattern in config_patterns:
|
|
274
|
+
for path in root.rglob(pattern):
|
|
275
|
+
if path.is_file():
|
|
276
|
+
if any(part in exclude_dirs for part in path.parts):
|
|
277
|
+
continue
|
|
278
|
+
try:
|
|
279
|
+
rel_path = str(path.relative_to(root))
|
|
280
|
+
config_files.append(rel_path)
|
|
281
|
+
except ValueError:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
return config_files
|
|
285
|
+
|
|
286
|
+
def _extract_config_patterns(
|
|
287
|
+
self, config_files: List[str], root: Path
|
|
288
|
+
) -> Dict[str, List[Tuple[str, int]]]:
|
|
289
|
+
"""Extract configuration patterns from config files.
|
|
290
|
+
|
|
291
|
+
Returns dict of {pattern: [(filepath, lineno)]}.
|
|
292
|
+
"""
|
|
293
|
+
patterns: Dict[str, List[Tuple[str, int]]] = {}
|
|
294
|
+
|
|
295
|
+
# Simple pattern matching for common config formats
|
|
296
|
+
var_patterns = [
|
|
297
|
+
(re.compile(r"^([A-Z_][A-Z0-9_]*)\s*="), "Environment variable"),
|
|
298
|
+
(re.compile(r"^\s*([a-z_][a-z0-9_]*)\s*:"), "YAML/INI key"),
|
|
299
|
+
(re.compile(r"^\s*\[([^\]]+)\]"), "Section header"),
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
for config_file in config_files:
|
|
303
|
+
filepath = root / config_file
|
|
304
|
+
try:
|
|
305
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
306
|
+
for lineno, line in enumerate(f, 1):
|
|
307
|
+
for pattern, description in var_patterns:
|
|
308
|
+
match = pattern.match(line)
|
|
309
|
+
if match:
|
|
310
|
+
key = f"{description}: {match.group(1)}"
|
|
311
|
+
if key not in patterns:
|
|
312
|
+
patterns[key] = []
|
|
313
|
+
patterns[key].append((config_file, lineno))
|
|
314
|
+
break
|
|
315
|
+
except Exception:
|
|
316
|
+
# Skip files that can't be read
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
return patterns
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Step: Configuration Schema Validation
|
|
3
|
+
Validate configuration files and environment variable schemas.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Set, Optional
|
|
9
|
+
|
|
10
|
+
from .base import Step, StepResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigValidationStep(Step):
|
|
14
|
+
"""Validate configuration schemas and .env completeness."""
|
|
15
|
+
|
|
16
|
+
name = "config validation"
|
|
17
|
+
|
|
18
|
+
def run(self, ctx: "BundleContext") -> StepResult: # type: ignore[name-defined]
|
|
19
|
+
"""Validate configuration schemas and environment files."""
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
start = time.time()
|
|
23
|
+
|
|
24
|
+
root = ctx.root
|
|
25
|
+
|
|
26
|
+
# Find configuration files
|
|
27
|
+
config_files = self._find_config_files(root)
|
|
28
|
+
env_file = root / ".env"
|
|
29
|
+
env_example = root / ".env.example"
|
|
30
|
+
|
|
31
|
+
# Analyze Pydantic settings
|
|
32
|
+
pydantic_settings = self._analyze_pydantic_settings(root)
|
|
33
|
+
|
|
34
|
+
# Compare .env and .env.example
|
|
35
|
+
env_comparison = self._compare_env_files(env_file, env_example)
|
|
36
|
+
|
|
37
|
+
# Generate report
|
|
38
|
+
lines = [
|
|
39
|
+
"=" * 80,
|
|
40
|
+
"CONFIGURATION VALIDATION REPORT",
|
|
41
|
+
"=" * 80,
|
|
42
|
+
"",
|
|
43
|
+
f"Project root: {root}",
|
|
44
|
+
"",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# Configuration files found
|
|
48
|
+
lines.extend(
|
|
49
|
+
[
|
|
50
|
+
"=" * 80,
|
|
51
|
+
"CONFIGURATION FILES",
|
|
52
|
+
"=" * 80,
|
|
53
|
+
"",
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if config_files:
|
|
58
|
+
for file_type, files in config_files.items():
|
|
59
|
+
lines.append(f"{file_type}:")
|
|
60
|
+
for f in files:
|
|
61
|
+
rel_path = f.relative_to(root)
|
|
62
|
+
lines.append(f" - {rel_path}")
|
|
63
|
+
lines.append("")
|
|
64
|
+
else:
|
|
65
|
+
lines.append("No standard configuration files found")
|
|
66
|
+
lines.append("")
|
|
67
|
+
|
|
68
|
+
# Pydantic settings analysis
|
|
69
|
+
if pydantic_settings:
|
|
70
|
+
lines.extend(
|
|
71
|
+
[
|
|
72
|
+
"=" * 80,
|
|
73
|
+
"PYDANTIC SETTINGS ANALYSIS",
|
|
74
|
+
"=" * 80,
|
|
75
|
+
"",
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
for settings_class, details in pydantic_settings.items():
|
|
80
|
+
lines.append(f"Class: {settings_class}")
|
|
81
|
+
lines.append(f" Location: {details['file']}")
|
|
82
|
+
lines.append(f" Fields: {details['field_count']}")
|
|
83
|
+
|
|
84
|
+
if details["fields"]:
|
|
85
|
+
lines.append(" Field annotations:")
|
|
86
|
+
for field, field_type in details["fields"][:10]:
|
|
87
|
+
lines.append(f" - {field}: {field_type}")
|
|
88
|
+
if len(details["fields"]) > 10:
|
|
89
|
+
lines.append(f" ... and {len(details['fields']) - 10} more")
|
|
90
|
+
|
|
91
|
+
lines.append("")
|
|
92
|
+
|
|
93
|
+
# .env file analysis
|
|
94
|
+
if env_file.exists() or env_example.exists():
|
|
95
|
+
lines.extend(
|
|
96
|
+
[
|
|
97
|
+
"=" * 80,
|
|
98
|
+
"ENVIRONMENT FILES ANALYSIS",
|
|
99
|
+
"=" * 80,
|
|
100
|
+
"",
|
|
101
|
+
]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if env_example.exists():
|
|
105
|
+
lines.append("✓ .env.example found")
|
|
106
|
+
example_count = len(env_comparison["example_vars"])
|
|
107
|
+
lines.append(f" Documented variables: {example_count}")
|
|
108
|
+
|
|
109
|
+
if env_comparison["example_vars"]:
|
|
110
|
+
lines.append(" Variables:")
|
|
111
|
+
for var in sorted(list(env_comparison["example_vars"]))[:15]:
|
|
112
|
+
lines.append(f" - {var}")
|
|
113
|
+
if len(env_comparison["example_vars"]) > 15:
|
|
114
|
+
lines.append(
|
|
115
|
+
f" ... and {len(env_comparison['example_vars']) - 15} more"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
lines.append("")
|
|
119
|
+
|
|
120
|
+
if env_file.exists():
|
|
121
|
+
lines.append("✓ .env found")
|
|
122
|
+
env_count = len(env_comparison["env_vars"])
|
|
123
|
+
lines.append(f" Variables set: {env_count}")
|
|
124
|
+
lines.append("")
|
|
125
|
+
|
|
126
|
+
# Comparison
|
|
127
|
+
if env_file.exists() and env_example.exists():
|
|
128
|
+
missing = env_comparison["missing_in_env"]
|
|
129
|
+
extra = env_comparison["extra_in_env"]
|
|
130
|
+
|
|
131
|
+
lines.append("ENV FILE COMPARISON:")
|
|
132
|
+
if missing:
|
|
133
|
+
lines.append(
|
|
134
|
+
f" ⚠ Missing in .env (documented but not set): {len(missing)}"
|
|
135
|
+
)
|
|
136
|
+
for var in sorted(missing)[:10]:
|
|
137
|
+
lines.append(f" - {var}")
|
|
138
|
+
if len(missing) > 10:
|
|
139
|
+
lines.append(f" ... and {len(missing) - 10} more")
|
|
140
|
+
else:
|
|
141
|
+
lines.append(" ✓ All documented variables are set in .env")
|
|
142
|
+
|
|
143
|
+
if extra:
|
|
144
|
+
lines.append(
|
|
145
|
+
f" ⚠ Extra in .env (not in .env.example): {len(extra)}"
|
|
146
|
+
)
|
|
147
|
+
for var in sorted(extra)[:10]:
|
|
148
|
+
lines.append(f" - {var}")
|
|
149
|
+
if len(extra) > 10:
|
|
150
|
+
lines.append(f" ... and {len(extra) - 10} more")
|
|
151
|
+
else:
|
|
152
|
+
lines.append(" ✓ All .env variables are documented in .env.example")
|
|
153
|
+
|
|
154
|
+
lines.append("")
|
|
155
|
+
|
|
156
|
+
# Recommendations
|
|
157
|
+
lines.extend(
|
|
158
|
+
[
|
|
159
|
+
"=" * 80,
|
|
160
|
+
"RECOMMENDATIONS",
|
|
161
|
+
"=" * 80,
|
|
162
|
+
"",
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if not env_example.exists():
|
|
167
|
+
lines.append(" - Create .env.example to document required variables")
|
|
168
|
+
if env_file.exists() and not env_example.exists():
|
|
169
|
+
lines.append(" - Use .env.example to version-control configuration schema")
|
|
170
|
+
if pydantic_settings:
|
|
171
|
+
lines.append(" - Pydantic settings detected: leverage validation at runtime")
|
|
172
|
+
if not pydantic_settings and (env_file.exists() or env_example.exists()):
|
|
173
|
+
lines.append(" - Consider using Pydantic BaseSettings for type-safe configuration")
|
|
174
|
+
|
|
175
|
+
lines.append("")
|
|
176
|
+
|
|
177
|
+
# Write report
|
|
178
|
+
output = "\n".join(lines)
|
|
179
|
+
dest = ctx.workdir / "logs" / "120_config_validation.txt"
|
|
180
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
dest.write_text(output, encoding="utf-8")
|
|
182
|
+
|
|
183
|
+
elapsed = int(time.time() - start)
|
|
184
|
+
return StepResult(self.name, "OK", elapsed, "")
|
|
185
|
+
|
|
186
|
+
def _find_config_files(self, root: Path) -> Dict[str, List[Path]]:
|
|
187
|
+
"""Find common configuration files."""
|
|
188
|
+
config_patterns = {
|
|
189
|
+
"YAML/YML": ["*.yaml", "*.yml"],
|
|
190
|
+
"TOML": ["*.toml"],
|
|
191
|
+
"JSON": ["*.json"],
|
|
192
|
+
"INI": ["*.ini", "*.cfg"],
|
|
193
|
+
"YAML/JSON": ["config.yaml", "config.json", "settings.yaml"],
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
found = {}
|
|
197
|
+
for file_type, patterns in config_patterns.items():
|
|
198
|
+
files = []
|
|
199
|
+
for pattern in patterns:
|
|
200
|
+
files.extend(root.glob(pattern))
|
|
201
|
+
if files:
|
|
202
|
+
found[file_type] = files
|
|
203
|
+
|
|
204
|
+
return found
|
|
205
|
+
|
|
206
|
+
def _analyze_pydantic_settings(self, root: Path) -> Dict[str, dict]:
|
|
207
|
+
"""Find and analyze Pydantic BaseSettings classes."""
|
|
208
|
+
settings = {}
|
|
209
|
+
|
|
210
|
+
python_files = root.rglob("*.py")
|
|
211
|
+
for py_file in python_files:
|
|
212
|
+
# Skip venv and cache
|
|
213
|
+
if any(
|
|
214
|
+
part in py_file.parts
|
|
215
|
+
for part in ["venv", ".venv", "env", "__pycache__", "site-packages"]
|
|
216
|
+
):
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
source = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
221
|
+
|
|
222
|
+
# Check if file imports BaseSettings
|
|
223
|
+
if "BaseSettings" not in source:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# Find class definitions that inherit from BaseSettings
|
|
227
|
+
class_pattern = (
|
|
228
|
+
r"class\s+(\w+)\s*\(\s*.*BaseSettings.*\s*\):"
|
|
229
|
+
)
|
|
230
|
+
matches = re.finditer(class_pattern, source)
|
|
231
|
+
|
|
232
|
+
for match in matches:
|
|
233
|
+
class_name = match.group(1)
|
|
234
|
+
rel_path = py_file.relative_to(root)
|
|
235
|
+
|
|
236
|
+
# Extract fields (simple annotation parsing)
|
|
237
|
+
fields = []
|
|
238
|
+
class_body_start = source.find(match.group(0)) + len(match.group(0))
|
|
239
|
+
class_body = source[class_body_start :]
|
|
240
|
+
|
|
241
|
+
# Find annotations before any method definitions
|
|
242
|
+
annotation_pattern = r"(\w+)\s*:\s*([^\n=]+)"
|
|
243
|
+
for field_match in re.finditer(annotation_pattern, class_body):
|
|
244
|
+
field_name = field_match.group(1)
|
|
245
|
+
field_type = field_match.group(2).strip()
|
|
246
|
+
|
|
247
|
+
# Stop at method definitions
|
|
248
|
+
if field_name == "def":
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
fields.append((field_name, field_type))
|
|
252
|
+
|
|
253
|
+
settings[class_name] = {
|
|
254
|
+
"file": str(rel_path),
|
|
255
|
+
"field_count": len(fields),
|
|
256
|
+
"fields": fields,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
except (OSError, UnicodeDecodeError):
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
return settings
|
|
263
|
+
|
|
264
|
+
def _compare_env_files(
|
|
265
|
+
self, env_file: Path, env_example: Path
|
|
266
|
+
) -> Dict[str, Set[str]]:
|
|
267
|
+
"""Compare .env and .env.example files."""
|
|
268
|
+
env_vars = set()
|
|
269
|
+
example_vars = set()
|
|
270
|
+
|
|
271
|
+
# Parse .env
|
|
272
|
+
if env_file.exists():
|
|
273
|
+
try:
|
|
274
|
+
content = env_file.read_text(encoding="utf-8", errors="ignore")
|
|
275
|
+
for line in content.split("\n"):
|
|
276
|
+
line = line.strip()
|
|
277
|
+
if line and not line.startswith("#") and "=" in line:
|
|
278
|
+
var_name = line.split("=")[0].strip()
|
|
279
|
+
if var_name:
|
|
280
|
+
env_vars.add(var_name)
|
|
281
|
+
except (OSError, UnicodeDecodeError):
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
# Parse .env.example
|
|
285
|
+
if env_example.exists():
|
|
286
|
+
try:
|
|
287
|
+
content = env_example.read_text(encoding="utf-8", errors="ignore")
|
|
288
|
+
for line in content.split("\n"):
|
|
289
|
+
line = line.strip()
|
|
290
|
+
if line and not line.startswith("#") and "=" in line:
|
|
291
|
+
var_name = line.split("=")[0].strip()
|
|
292
|
+
if var_name:
|
|
293
|
+
example_vars.add(var_name)
|
|
294
|
+
except (OSError, UnicodeDecodeError):
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
"env_vars": env_vars,
|
|
299
|
+
"example_vars": example_vars,
|
|
300
|
+
"missing_in_env": example_vars - env_vars,
|
|
301
|
+
"extra_in_env": env_vars - example_vars,
|
|
302
|
+
}
|