Skip to content

Commit 954f71f

Browse files
committed
WIP generated settings validation
1 parent 8050726 commit 954f71f

File tree

3 files changed

+242
-0
lines changed

3 files changed

+242
-0
lines changed

main/env_validator.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""
2+
Environment variable validation utilities for detecting configuration discrepancies.
3+
"""
4+
5+
import logging
6+
import os
7+
import re
8+
from pathlib import Path
9+
from typing import Dict, List, Tuple, Optional
10+
11+
logger = logging.getLogger(__name__)
12+
13+
def strip_comment(val):
14+
return val.split('#', 1)[0].strip()
15+
16+
class EnvValidator:
17+
"""Validates environment variable configurations and reports discrepancies."""
18+
19+
def __init__(self, project_root: Optional[str] = None):
20+
"""
21+
Initialize the environment validator.
22+
23+
Args:
24+
project_root: Root directory of the project. Defaults to parent of current file.
25+
"""
26+
if project_root is None:
27+
project_root = Path(__file__).parent.parent
28+
else:
29+
project_root = Path(project_root)
30+
31+
self.project_root = project_root
32+
self.env_dir = project_root / "env"
33+
34+
def _parse_env_file(self, file_path: Path) -> Dict[str, dict]:
35+
"""
36+
Parse an environment file and return a dictionary of key-value pairs and any noqa-style directives.
37+
38+
Args:
39+
file_path: Path to the environment file
40+
41+
Returns:
42+
Dictionary mapping variable names to dicts with 'value' and optional 'directive'.
43+
"""
44+
env_vars = {}
45+
46+
if not file_path.exists():
47+
return env_vars
48+
49+
try:
50+
with open(file_path, 'r', encoding='utf-8') as f:
51+
for line_num, line in enumerate(f, 1):
52+
line = line.strip()
53+
54+
# Skip empty lines and comments
55+
if not line or line.startswith('#'):
56+
continue
57+
58+
# Check for noqa-style directive at end of line
59+
directive = None
60+
if line.endswith("# local-required"):
61+
directive = "local-required"
62+
line = line[: -len("# local-required")].rstrip()
63+
elif line.endswith("# suppress-warning"):
64+
directive = "suppress-warning"
65+
line = line[: -len("# suppress-warning")].rstrip()
66+
67+
# Parse regular env variables
68+
if '=' in line:
69+
key, value = line.split('=', 1)
70+
key = key.strip()
71+
value = value.strip()
72+
73+
env_vars[key] = {'value': value}
74+
if directive:
75+
env_vars[key]['directive'] = directive
76+
77+
except Exception as e:
78+
logger.warning(f"Error parsing env file {file_path}: {e}")
79+
80+
return env_vars
81+
82+
def _get_env_file_pairs(self) -> List[Tuple[str, Path, Path, Path]]:
83+
"""
84+
Get pairs of environment files to compare.
85+
86+
Returns:
87+
List of tuples: (env_type, base_env_path, local_env_path, example_env_path)
88+
"""
89+
pairs = []
90+
91+
# Define the environment file patterns
92+
env_patterns = [
93+
("backend", "backend.env", "backend.local.env", "backend.local.example.env"),
94+
("frontend", "frontend.env", "frontend.local.env", "frontend.local.example.env"),
95+
("shared", "shared.env", "shared.local.env", "shared.local.example.env"),
96+
]
97+
98+
for env_type, base_name, local_name, example_name in env_patterns:
99+
base_path = self.env_dir / base_name
100+
local_path = self.env_dir / local_name
101+
example_path = self.env_dir / example_name
102+
103+
# Include if any of the files exist (we'll check existence in validation methods)
104+
if base_path.exists() or local_path.exists() or example_path.exists():
105+
pairs.append((env_type, base_path, local_path, example_path))
106+
107+
return pairs
108+
109+
def check_example_overrides(self) -> List[str]:
110+
"""
111+
Check for settings present in example files but not in base env files.
112+
This identifies non-standard settings that are overridden in the environment.
113+
114+
Returns:
115+
List of warning messages
116+
"""
117+
warnings = []
118+
119+
for env_type, base_path, local_path, example_path in self._get_env_file_pairs():
120+
if not example_path.exists():
121+
continue
122+
123+
base_vars = self._parse_env_file(base_path) if base_path.exists() else {}
124+
example_vars = self._parse_env_file(example_path)
125+
126+
example_only = set(example_vars.keys()) - set(base_vars.keys())
127+
128+
for var_name in example_only:
129+
# Only warn if the variable is present in local file
130+
if local_path.exists():
131+
local_vars = self._parse_env_file(local_path)
132+
if var_name in local_vars:
133+
example_value = example_vars[var_name]['value']
134+
local_value = local_vars[var_name]['value']
135+
if strip_comment(local_value) == strip_comment(example_value):
136+
continue
137+
warnings.append(
138+
f"⚠️ {env_type.upper()}: Variable '{var_name}' is set in {local_path.name} "
139+
f"but not defined in {base_path.name}. This overrides a non-standard setting "
140+
f"from {example_path.name}. Local value: '{local_value}', "
141+
f"Example value: '{example_value}'"
142+
)
143+
return warnings
144+
145+
def check_local_overrides(self) -> List[str]:
146+
"""
147+
Check for settings in local files that differ from or are absent in base files.
148+
149+
Returns:
150+
List of warning messages
151+
"""
152+
warnings = []
153+
154+
for env_type, base_path, local_path, example_path in self._get_env_file_pairs():
155+
if not local_path.exists():
156+
continue
157+
158+
base_vars = self._parse_env_file(base_path) if base_path.exists() else {}
159+
local_vars = self._parse_env_file(local_path)
160+
161+
for var_name, local_info in local_vars.items():
162+
local_value = local_info['value']
163+
suppress = local_info.get('directive') == 'suppress-warning'
164+
if var_name not in base_vars:
165+
if suppress:
166+
continue
167+
warnings.append(
168+
f"⚠️ {env_type.upper()}: Variable '{var_name}' is set in {local_path.name} "
169+
f"(value: '{local_value}') but not defined in {base_path.name}. "
170+
f"Consider adding a default value to {base_path.name} if this should be a standard setting."
171+
)
172+
else:
173+
base_value = base_vars[var_name]['value']
174+
# Compare values ignoring anything after a "#" symbol
175+
if strip_comment(base_value) == strip_comment(local_value):
176+
continue # No warning if values match (ignoring comments)
177+
# Suppress warning if base file has # local-required
178+
if base_vars[var_name].get('directive') == 'local-required':
179+
continue
180+
warnings.append(
181+
f"⚠️ {env_type.upper()}: Variable '{var_name}' is overridden locally. "
182+
f"Base value: '{base_value}', Local value: '{local_value}'. "
183+
f"Ensure the default in {base_path.name} is appropriate."
184+
)
185+
return warnings
186+
187+
def validate_all(self) -> List[str]:
188+
"""
189+
Run all environment validation checks.
190+
191+
Returns:
192+
List of all warning messages
193+
"""
194+
warnings = []
195+
warnings.extend(self.check_example_overrides())
196+
warnings.extend(self.check_local_overrides())
197+
return warnings
198+
199+
def log_warnings(self, warnings: List[str]) -> None:
200+
"""
201+
Log warning messages.
202+
203+
Args:
204+
warnings: List of warning messages to log
205+
"""
206+
if not warnings:
207+
logger.info("✅ Environment validation passed - no configuration discrepancies found.")
208+
return
209+
210+
logger.warning("Environment Configuration Warnings:")
211+
logger.warning("=" * 60)
212+
for warning in warnings:
213+
logger.warning(warning)
214+
logger.warning("=" * 60)
215+
logger.warning(
216+
f"Found {len(warnings)} environment configuration issue(s). "
217+
"Review your environment files to ensure proper configuration."
218+
)
219+
220+
221+
def validate_environment_on_startup():
222+
"""
223+
Main function to validate environment configuration on startup.
224+
This can be called from Django settings or management commands.
225+
"""
226+
validator = EnvValidator()
227+
warnings = validator.validate_all()
228+
validator.log_warnings(warnings)
229+
return warnings
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

main/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@
5555
profiles_sample_rate=SENTRY_PROFILES_SAMPLE_RATE,
5656
)
5757

58+
# Validate environment configuration on startup
59+
# Skip validation during testing or when explicitly disabled
60+
if not get_bool("SKIP_ENV_VALIDATION", False) and "test" not in os.environ.get("DJANGO_SETTINGS_MODULE", ""):
61+
try:
62+
from main.env_validator import validate_environment_on_startup
63+
validate_environment_on_startup()
64+
except ImportError as e:
65+
log.warning(f"Could not import environment validator: {e}")
66+
except Exception as e:
67+
log.warning(f"Environment validation failed: {e}")
68+
5869
BASE_DIR = os.path.dirname( # noqa: PTH120
5970
os.path.dirname(os.path.abspath(__file__)) # noqa: PTH100, PTH120
6071
)
@@ -866,3 +877,4 @@ def get_all_config_keys():
866877
OPENTELEMETRY_TRACES_BATCH_SIZE = get_int("OPENTELEMETRY_TRACES_BATCH_SIZE", 512)
867878
OPENTELEMETRY_EXPORT_TIMEOUT_MS = get_int("OPENTELEMETRY_EXPORT_TIMEOUT_MS", 5000)
868879
CANVAS_TUTORBOT_FOLDER = get_string("CANVAS_TUTORBOT_FOLDER", "web_resources/ai/tutor/")
880+

0 commit comments

Comments
 (0)