Skip to content

Commit 524ff3a

Browse files
authored
Update cli.py
1 parent 89c37a7 commit 524ff3a

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

cli.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,210 @@
1+
import os
2+
import sys
3+
import json
4+
import click
5+
from typing import List, Dict, Any, Tuple
6+
from ruamel.yaml import YAML
7+
import io
18

9+
# --- Configuration ---
10+
SPEC_FILE_NAME = "env.spec"
11+
DOTENV_FILE_NAME = ".env"
12+
VALID_FORMATS = ['text', 'json', 'yaml']
13+
14+
# Define valid types and conversion attempts
15+
VALID_TYPES = {
16+
"string": str,
17+
"integer": int,
18+
"float": float,
19+
"boolean": bool
20+
}
21+
22+
def check_value_type(key: str, value: str, expected_type: str) -> Tuple[bool, str]:
23+
"""Checks if the given value conforms to the expected type."""
24+
if expected_type == 'string':
25+
# All non-empty values are valid strings
26+
return True, ""
27+
28+
# Try type conversion for numerical and boolean types
29+
try:
30+
if expected_type == 'integer':
31+
# Check for float format in integer type
32+
if '.' in value:
33+
return False, f"Value '{value}' contains a decimal point. Expected strict integer."
34+
35+
int(value) # Will raise ValueError if conversion fails
36+
elif expected_type == 'float':
37+
float(value)
38+
elif expected_type == 'boolean':
39+
# Accept case-insensitive 'true', 'false', '1', '0'
40+
lower_value = value.lower()
41+
if lower_value not in ('true', 'false', '1', '0'):
42+
return False, f"Value '{value}' is not a valid boolean (expected true/false/1/0)."
43+
return True, ""
44+
except ValueError:
45+
return False, f"Value '{value}' cannot be converted to type '{expected_type}'."
46+
except Exception as e:
47+
return False, f"An unexpected error occurred during type check: {e}"
48+
49+
50+
def load_spec_file(spec_path: str) -> Dict[str, str]:
51+
"""Loads required variables and their types from the env.spec YAML file."""
52+
yaml = YAML()
53+
try:
54+
with open(spec_path, 'r', encoding='utf-8') as f:
55+
data = yaml.load(f)
56+
57+
if not isinstance(data, dict):
58+
raise ValueError(f"Specification file '{spec_path}' content must be a dictionary (YAML object).")
59+
60+
# Normalize keys to strings, values (types) to lowercase strings
61+
normalized_data = {}
62+
for k, v in data.items():
63+
if isinstance(v, str):
64+
normalized_data[str(k).strip()] = v.lower().strip()
65+
else:
66+
raise ValueError(f"Type for variable '{k}' must be a string, not '{type(v).__name__}'.")
67+
68+
# Basic validation of types
69+
for var, expected_type in normalized_data.items():
70+
if expected_type not in VALID_TYPES:
71+
raise ValueError(f"Variable '{var}' specifies an unknown type: '{expected_type}'. Valid types are: {', '.join(VALID_TYPES.keys())}")
72+
73+
return normalized_data
74+
75+
except FileNotFoundError:
76+
click.echo(f"Error: Specification file not found at '{spec_path}'.", err=True)
77+
sys.exit(1)
78+
except Exception as e:
79+
click.echo(f"Error reading specification file: {e}", err=True)
80+
sys.exit(1)
81+
82+
83+
def load_dotenv_vars(dotenv_path: str) -> Dict[str, str]:
84+
"""Loads variables from the optional .env file (simple implementation)."""
85+
dotenv_vars = {}
86+
if os.path.exists(dotenv_path):
87+
try:
88+
with open(dotenv_path, 'r', encoding='utf-8') as f:
89+
for line in f:
90+
line = line.strip()
91+
if line and not line.startswith('#'):
92+
# Basic parsing, handling key=value
93+
if '=' in line:
94+
key, value = line.split('=', 1)
95+
key = key.strip()
96+
value = value.strip().strip('"').strip("'") # Simple removal of quotes
97+
if key:
98+
dotenv_vars[key] = value
99+
except Exception as e:
100+
click.echo(f"Warning: Could not parse .env file: {e}", err=True)
101+
return dotenv_vars
102+
103+
def format_output(data: Dict[str, Any], output_format: str) -> str:
104+
"""Formats the check report based on the requested output format."""
105+
106+
if output_format == 'json':
107+
return json.dumps(data, indent=4)
108+
109+
if output_format == 'yaml':
110+
yaml = YAML()
111+
yaml.indent(mapping=2, sequence=4, offset=2)
112+
113+
# Prepare YAML output string
114+
output_buffer = io.StringIO()
115+
yaml.dump(data, output_buffer)
116+
return output_buffer.getvalue()
117+
118+
# Default 'text' format
119+
report = f"\n--- 🛡️ EnvSanityCheck: Starting Sanity Check ---\n"
120+
121+
if data['status'] == 'SUCCESS':
122+
report += f"\n✅ SUCCESS! All {data['required_count']} required variables are set correctly."
123+
else:
124+
report += "\n❌ VALIDATION FAILURE:\n"
125+
126+
if data['missing']:
127+
report += "\n❌ MISSING VARIABLES:\n"
128+
for var in data['missing']:
129+
report += f" - {var}\n"
130+
report += " -> Please add these variables to your environment or .env file.\n"
131+
132+
if data['empty']:
133+
report += "\n⚠️ EMPTY VARIABLES:\n"
134+
for var in data['empty']:
135+
report += f" - {var}\n"
136+
report += " -> These variables are set but empty. They must have a value.\n"
137+
138+
if data['type_errors']:
139+
report += "\n⚠️ TYPE MISMATCH ERRORS:\n"
140+
for error in data['type_errors']:
141+
report += f" - {error['key']} | Expected: {error['expected']} | Found: '{error['actual_value']}'\n"
142+
report += f" Reason: {error['message']}\n"
143+
144+
report += f"\n--- EnvSanityCheck: {len(data['missing'])} Missing, {len(data['empty'])} Empty, {len(data['type_errors'])} Type Errors (Total Errors: {len(data['missing']) + len(data['empty']) + len(data['type_errors'])}) ---\n"
145+
report += "Please fix the errors listed above."
146+
147+
report += "\n--- EnvSanityCheck: Finished ---\n"
148+
149+
return report
150+
151+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
152+
@click.option('--spec', default=SPEC_FILE_NAME, help='Name of the specification file listing required variables. Default: env.spec')
153+
@click.option('--format', type=click.Choice(VALID_FORMATS), default='text', help="Output format: 'text' (default), 'json', or 'yaml'.")
154+
def envsanitycheck(spec: str, format: str):
155+
"""
156+
EnvSanityCheck: Checks if all required environment variables for the project are set.
157+
"""
158+
159+
required_vars_with_types = load_spec_file(spec)
160+
161+
# Load variables from .env file and then os.environ (OS environment wins)
162+
dotenv_vars = load_dotenv_vars(DOTENV_FILE_NAME)
163+
all_available_vars = {**dotenv_vars, **os.environ}
164+
165+
missing_vars = []
166+
empty_vars = []
167+
type_errors = []
168+
found_count = 0
169+
170+
for var, expected_type in required_vars_with_types.items():
171+
if var not in all_available_vars:
172+
missing_vars.append(var)
173+
else:
174+
value = all_available_vars[var]
175+
if not value:
176+
empty_vars.append(var)
177+
else:
178+
# New: Type Check
179+
is_valid, message = check_value_type(var, value, expected_type)
180+
if not is_valid:
181+
type_errors.append({"key": var, "expected": expected_type, "actual_value": value, "message": message})
182+
else:
183+
found_count += 1
184+
185+
is_failing = bool(missing_vars or empty_vars or type_errors)
186+
187+
# Prepare Structured Data
188+
report_data = {
189+
"status": "FAILURE" if is_failing else "SUCCESS",
190+
"required_count": len(required_vars_with_types),
191+
"found_count": found_count,
192+
"missing": missing_vars,
193+
"empty": empty_vars,
194+
"type_errors": type_errors,
195+
"all_checks_passed": not is_failing
196+
}
197+
198+
# Output the report
199+
report = format_output(report_data, format)
200+
click.echo(report)
201+
202+
# Exit with appropriate code for CI/CD pipeline
203+
if is_failing:
204+
sys.exit(1)
205+
206+
# Success exit code 0 is implied if sys.exit(1) is not called.
207+
208+
209+
if __name__ == '__main__':
210+
envsanitycheck()

0 commit comments

Comments
 (0)