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.

Files changed (82) hide show
  1. gwc_pybundle-2.1.2.dist-info/METADATA +903 -0
  2. gwc_pybundle-2.1.2.dist-info/RECORD +82 -0
  3. gwc_pybundle-2.1.2.dist-info/WHEEL +5 -0
  4. gwc_pybundle-2.1.2.dist-info/entry_points.txt +2 -0
  5. gwc_pybundle-2.1.2.dist-info/licenses/LICENSE.md +25 -0
  6. gwc_pybundle-2.1.2.dist-info/top_level.txt +1 -0
  7. pybundle/__init__.py +0 -0
  8. pybundle/__main__.py +4 -0
  9. pybundle/cli.py +546 -0
  10. pybundle/context.py +404 -0
  11. pybundle/doctor.py +148 -0
  12. pybundle/filters.py +228 -0
  13. pybundle/manifest.py +77 -0
  14. pybundle/packaging.py +45 -0
  15. pybundle/policy.py +132 -0
  16. pybundle/profiles.py +454 -0
  17. pybundle/roadmap_model.py +42 -0
  18. pybundle/roadmap_scan.py +328 -0
  19. pybundle/root_detect.py +14 -0
  20. pybundle/runner.py +180 -0
  21. pybundle/steps/__init__.py +26 -0
  22. pybundle/steps/ai_context.py +791 -0
  23. pybundle/steps/api_docs.py +219 -0
  24. pybundle/steps/asyncio_analysis.py +358 -0
  25. pybundle/steps/bandit.py +72 -0
  26. pybundle/steps/base.py +20 -0
  27. pybundle/steps/blocking_call_detection.py +291 -0
  28. pybundle/steps/call_graph.py +219 -0
  29. pybundle/steps/compileall.py +76 -0
  30. pybundle/steps/config_docs.py +319 -0
  31. pybundle/steps/config_validation.py +302 -0
  32. pybundle/steps/container_image.py +294 -0
  33. pybundle/steps/context_expand.py +272 -0
  34. pybundle/steps/copy_pack.py +293 -0
  35. pybundle/steps/coverage.py +101 -0
  36. pybundle/steps/cprofile_step.py +166 -0
  37. pybundle/steps/dependency_sizes.py +136 -0
  38. pybundle/steps/django_checks.py +214 -0
  39. pybundle/steps/dockerfile_lint.py +282 -0
  40. pybundle/steps/dockerignore.py +311 -0
  41. pybundle/steps/duplication.py +103 -0
  42. pybundle/steps/env_completeness.py +269 -0
  43. pybundle/steps/env_var_usage.py +253 -0
  44. pybundle/steps/error_refs.py +204 -0
  45. pybundle/steps/event_loop_patterns.py +280 -0
  46. pybundle/steps/exception_patterns.py +190 -0
  47. pybundle/steps/fastapi_integration.py +250 -0
  48. pybundle/steps/flask_debugging.py +312 -0
  49. pybundle/steps/git_analytics.py +315 -0
  50. pybundle/steps/handoff_md.py +176 -0
  51. pybundle/steps/import_time.py +175 -0
  52. pybundle/steps/interrogate.py +106 -0
  53. pybundle/steps/license_scan.py +96 -0
  54. pybundle/steps/line_profiler.py +117 -0
  55. pybundle/steps/link_validation.py +287 -0
  56. pybundle/steps/logging_analysis.py +233 -0
  57. pybundle/steps/memory_profile.py +176 -0
  58. pybundle/steps/migration_history.py +336 -0
  59. pybundle/steps/mutation_testing.py +141 -0
  60. pybundle/steps/mypy.py +103 -0
  61. pybundle/steps/orm_optimization.py +316 -0
  62. pybundle/steps/pip_audit.py +45 -0
  63. pybundle/steps/pipdeptree.py +62 -0
  64. pybundle/steps/pylance.py +562 -0
  65. pybundle/steps/pytest.py +66 -0
  66. pybundle/steps/query_pattern_analysis.py +334 -0
  67. pybundle/steps/radon.py +161 -0
  68. pybundle/steps/repro_md.py +161 -0
  69. pybundle/steps/rg_scans.py +78 -0
  70. pybundle/steps/roadmap.py +153 -0
  71. pybundle/steps/ruff.py +117 -0
  72. pybundle/steps/secrets_detection.py +235 -0
  73. pybundle/steps/security_headers.py +309 -0
  74. pybundle/steps/shell.py +74 -0
  75. pybundle/steps/slow_tests.py +178 -0
  76. pybundle/steps/sqlalchemy_validation.py +269 -0
  77. pybundle/steps/test_flakiness.py +184 -0
  78. pybundle/steps/tree.py +116 -0
  79. pybundle/steps/type_coverage.py +277 -0
  80. pybundle/steps/unused_deps.py +211 -0
  81. pybundle/steps/vulture.py +167 -0
  82. 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
+ }