Skip to content

Commit 9150121

Browse files
ritwik-gclaude
andcommitted
Implement Task 4.2: Generator command with YAML output
Core Features: - Added `generate --env <env>` command that outputs values.yaml to stdout - Validates configuration before generation (fails if validation errors) - Merges schema defaults with environment-specific values - Resolves secrets from environment variables - Builds nested YAML structure using dot-path notation Implementation Details: - helm_values_manager/generator.py: Core generation logic with secret resolution - helm_values_manager/commands/generate.py: CLI command implementation - Added PyYAML dependency for YAML output generation - Error output goes to stderr, YAML output to stdout for piping Validation Improvements: - Updated validate command to use new validate_single_environment function - Added schema integrity checks (duplicate keys/paths detection) - Fixed Rich console bracket escaping for environment names in errors - Proper error handling for JSON decode errors and missing files Testing: - Comprehensive test suite covering all generation scenarios - Tests for validation integration, secret resolution, and error handling - Complex real-world example with multiple value types and secrets - All 84 tests passing with 77% coverage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9f1edc3 commit 9150121

File tree

11 files changed

+865
-28
lines changed

11 files changed

+865
-28
lines changed

helm_values_manager/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from helm_values_manager import __version__
55
from helm_values_manager.commands.init import init_command
6-
from helm_values_manager.commands import schema, values, validate
6+
from helm_values_manager.commands import schema, values, validate, generate
77

88
app = typer.Typer(
99
name="helm-values-manager",
@@ -37,6 +37,7 @@ def main(
3737
app.add_typer(schema.app, name="schema", help="Manage schema values")
3838
app.add_typer(values.app, name="values", help="Manage environment values")
3939
app.command("validate")(validate.validate_command)
40+
app.command("generate")(generate.generate_command)
4041

4142

4243
if __name__ == "__main__":
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Commands module for helm-values-manager."""
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Generate command implementation."""
2+
import sys
3+
from typing import Optional
4+
5+
import typer
6+
from rich.console import Console
7+
8+
from helm_values_manager.generator import GeneratorError, generate_values
9+
from helm_values_manager.utils import get_values_file_path, load_schema, load_values
10+
from helm_values_manager.validator import validate_single_environment
11+
12+
console = Console()
13+
err_console = Console(stderr=True)
14+
app = typer.Typer()
15+
16+
17+
@app.command()
18+
def generate_command(
19+
env: str = typer.Option(..., "--env", "-e", help="Environment to generate values for"),
20+
schema: str = typer.Option("schema.json", "--schema", "-s", help="Path to schema file"),
21+
values: Optional[str] = typer.Option(None, "--values", help="Path to values file (default: values-{env}.json)"),
22+
):
23+
"""Generate values.yaml for a specific environment."""
24+
# Load schema
25+
schema_obj = load_schema(schema)
26+
if not schema_obj:
27+
err_console.print("[red]Error:[/red] Schema file not found")
28+
raise typer.Exit(1)
29+
30+
# Determine values file path
31+
values_path = values or get_values_file_path(env)
32+
33+
# Load values
34+
values_data = load_values(env, values)
35+
36+
# Run validation first
37+
errors = validate_single_environment(schema_obj, values_data, env)
38+
39+
if errors:
40+
err_console.print("[red]Error:[/red] Validation failed. Please fix the following issues:")
41+
for error in errors:
42+
err_console.print(f" - {error}")
43+
raise typer.Exit(1)
44+
45+
# Generate values.yaml
46+
try:
47+
yaml_content = generate_values(schema_obj, values_data, env)
48+
# Output to stdout
49+
print(yaml_content, end='')
50+
except GeneratorError as e:
51+
err_console.print(f"[red]Error:[/red] {e}")
52+
raise typer.Exit(1)
53+
except Exception as e:
54+
err_console.print(f"[red]Error:[/red] Failed to generate values: {e}")
55+
raise typer.Exit(1)

helm_values_manager/commands/validate.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Validate command for helm-values-manager."""
2+
import json
23
from pathlib import Path
34
from typing import Optional
45

56
import typer
6-
77
from rich.console import Console
88

9+
from helm_values_manager.models import Schema
10+
from helm_values_manager.utils import load_schema, load_values, get_values_file_path
11+
from helm_values_manager.validator import validate_single_environment
12+
913
console = Console()
1014

1115

@@ -24,13 +28,36 @@ def validate_command(
2428
),
2529
):
2630
"""Validate schema and values file for a specific environment."""
27-
# Import here to avoid circular imports
28-
from helm_values_manager.validator import validate_single_environment
29-
31+
# Check if schema file exists first
3032
schema_path = Path(schema)
33+
if not schema_path.exists():
34+
console.print("[red]Error:[/red] Schema file not found")
35+
raise typer.Exit(1)
36+
37+
# Load schema
38+
try:
39+
with open(schema_path) as f:
40+
data = json.load(f)
41+
schema_obj = Schema(**data)
42+
except json.JSONDecodeError:
43+
console.print("[red]Error:[/red] Invalid JSON in schema file")
44+
raise typer.Exit(1)
45+
except Exception as e:
46+
console.print(f"[red]Error:[/red] Invalid schema: {e}")
47+
raise typer.Exit(1)
48+
49+
# Load values
50+
values_data = load_values(env, values)
3151

32-
# Run validation for single environment
33-
success = validate_single_environment(schema_path, env, values)
52+
# Run validation
53+
errors = validate_single_environment(schema_obj, values_data, env)
3454

35-
if not success:
36-
raise typer.Exit(code=1)
55+
if errors:
56+
console.print("[red]Error:[/red] Validation failed:")
57+
for error in errors:
58+
# Escape square brackets for Rich
59+
escaped_error = error.replace("[", "\\[").replace("]", "]")
60+
console.print(f" - {escaped_error}")
61+
raise typer.Exit(1)
62+
else:
63+
console.print(f"[green]✅[/green] Validation passed for environment: {env}")

helm_values_manager/generator.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Generator module for creating values.yaml from schema and values."""
2+
import os
3+
from typing import Any, Dict, Optional
4+
5+
import yaml
6+
7+
from helm_values_manager.models import Schema, SchemaValue, SecretReference
8+
from helm_values_manager.utils import is_secret_reference
9+
10+
11+
class GeneratorError(Exception):
12+
"""Base exception for generator errors."""
13+
pass
14+
15+
16+
def resolve_secret(secret: Dict[str, Any], key: str) -> str:
17+
"""Resolve a secret reference to its actual value.
18+
19+
Args:
20+
secret: Secret reference with type and name
21+
key: The key name (for error messages)
22+
23+
Returns:
24+
The resolved secret value
25+
26+
Raises:
27+
GeneratorError: If secret cannot be resolved
28+
"""
29+
if secret.get("type") != "env":
30+
raise GeneratorError(f"Unsupported secret type '{secret.get('type')}' for key '{key}'")
31+
32+
env_var = secret.get("name")
33+
if not env_var:
34+
raise GeneratorError(f"Missing environment variable name for secret '{key}'")
35+
36+
value = os.environ.get(env_var)
37+
if value is None:
38+
raise GeneratorError(f"Environment variable '{env_var}' not found for secret '{key}'")
39+
40+
return value
41+
42+
43+
def build_nested_dict(flat_values: Dict[str, Any], schema: Schema) -> Dict[str, Any]:
44+
"""Build a nested dictionary from flat values using schema paths.
45+
46+
Args:
47+
flat_values: Flat dictionary with keys and values
48+
schema: Schema containing path information
49+
50+
Returns:
51+
Nested dictionary following YAML structure
52+
"""
53+
result = {}
54+
55+
# Create a mapping of keys to schema values for easy lookup
56+
key_to_schema = {sv.key: sv for sv in schema.values}
57+
58+
for key, value in flat_values.items():
59+
schema_value = key_to_schema.get(key)
60+
if not schema_value:
61+
# Skip unknown keys (validation should catch this)
62+
continue
63+
64+
# Split the path into parts
65+
path_parts = schema_value.path.split('.')
66+
67+
# Navigate/create the nested structure
68+
current = result
69+
for i, part in enumerate(path_parts[:-1]):
70+
if part not in current:
71+
current[part] = {}
72+
elif not isinstance(current[part], dict):
73+
# Path conflict - this shouldn't happen with valid schema
74+
raise GeneratorError(f"Path conflict at '{'.'.join(path_parts[:i+1])}' for key '{key}'")
75+
current = current[part]
76+
77+
# Set the final value
78+
final_key = path_parts[-1]
79+
current[final_key] = value
80+
81+
return result
82+
83+
84+
def generate_values(schema: Schema, values: Dict[str, Any], env: str) -> str:
85+
"""Generate values.yaml content from schema and environment values.
86+
87+
Args:
88+
schema: The schema definition
89+
values: The environment-specific values
90+
env: Environment name (for error messages)
91+
92+
Returns:
93+
YAML content as string
94+
95+
Raises:
96+
GeneratorError: If generation fails
97+
"""
98+
# Start with defaults from schema
99+
merged_values = {}
100+
101+
for schema_value in schema.values:
102+
if schema_value.default is not None:
103+
merged_values[schema_value.key] = schema_value.default
104+
105+
# Override with environment values and resolve secrets
106+
for key, value in values.items():
107+
if is_secret_reference(value):
108+
try:
109+
merged_values[key] = resolve_secret(value, key)
110+
except GeneratorError as e:
111+
raise GeneratorError(f"[{env}] {e}")
112+
else:
113+
merged_values[key] = value
114+
115+
# Build nested structure
116+
nested_values = build_nested_dict(merged_values, schema)
117+
118+
# Convert to YAML
119+
yaml_content = yaml.dump(
120+
nested_values,
121+
default_flow_style=False,
122+
allow_unicode=True,
123+
sort_keys=False,
124+
width=1000 # Avoid line wrapping for long strings
125+
)
126+
127+
return yaml_content

helm_values_manager/utils.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,17 @@ def load_schema(schema_path: str = "schema.json") -> Optional[Schema]:
1111
if not path.exists():
1212
return None
1313

14-
with open(path) as f:
15-
data = json.load(f)
16-
17-
return Schema(**data)
14+
try:
15+
with open(path) as f:
16+
data = json.load(f)
17+
return Schema(**data)
18+
except json.JSONDecodeError:
19+
# Return None to indicate file exists but is invalid
20+
# The caller should handle this appropriately
21+
return None
22+
except Exception:
23+
# For any other error (validation, etc.)
24+
return None
1825

1926

2027
def save_schema(schema: Schema, schema_path: str = "schema.json") -> None:

0 commit comments

Comments
 (0)