From 4434bd2577fc413e71edd3b8daade96324d6bcda Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 27 Jun 2026 14:31:06 -0300 Subject: [PATCH] fix: report enum variable validation errors cleanly --- structkit/commands/generate.py | 8 ++++-- structkit/template_renderer.py | 30 +++++++++++++++++++--- tests/test_commands_more.py | 20 +++++++++++++++ tests/test_template_renderer.py | 44 ++++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/structkit/commands/generate.py b/structkit/commands/generate.py index 2bae3bc..6f8c731 100644 --- a/structkit/commands/generate.py +++ b/structkit/commands/generate.py @@ -4,7 +4,7 @@ import argparse from structkit.file_item import FileItem from structkit.completers import file_strategy_completer, structures_completer -from structkit.template_renderer import TemplateRenderer +from structkit.template_renderer import TemplateRenderer, TemplateVariableError from structkit.sources import SourceError, resolve_structures_path import subprocess @@ -186,7 +186,11 @@ def execute(self, args): return # Actually generate structure - self._create_structure(args, mappings) + try: + self._create_structure(args, mappings) + except TemplateVariableError as exc: + self.logger.error(f"❗ {exc}") + raise SystemExit(1) from None # Run post-hooks if not self._run_hooks(post_hooks, hook_type="post"): diff --git a/structkit/template_renderer.py b/structkit/template_renderer.py index 1a2a805..9e151f7 100644 --- a/structkit/template_renderer.py +++ b/structkit/template_renderer.py @@ -19,6 +19,10 @@ from structkit.utils import get_current_repo +class TemplateVariableError(ValueError): + """Raised when a template variable is missing or fails validation.""" + + class TemplateRenderer: def __init__(self, config_variables, input_store, non_interactive, mappings=None): self.config_variables = config_variables @@ -147,8 +151,15 @@ def prompt_for_missing_vars(self, content, vars): self.logger.debug(f"Default values from config: {default_values}") for var in undeclared_variables: + conf = schema.get(var, {}) + if var in vars: + if conf: + coerced = self._coerce_and_validate(var, vars[var], conf) + self.input_store.set_value(var, coerced) + vars[var] = coerced + continue + if var not in vars: - conf = schema.get(var, {}) required = conf.get('required', False) default = self.input_data.get(var, default_values.get(var, "")) if self.non_interactive: @@ -190,7 +201,7 @@ def prompt_for_missing_vars(self, content, vars): user_input = raw else: # For invalid enum input, raise immediately instead of re-prompting - raise ValueError(f"Variable '{var}' must be one of {enum}, got: {raw}") + raise self._enum_value_error(var, raw, enum) else: if description: print(f"{icon} {BOLD}{var}{RESET}: {description}") @@ -223,12 +234,12 @@ def _coerce_and_validate(self, name, value, conf): else: coerced = '' if value is None else str(value) except Exception: - raise ValueError(f"Variable '{name}' could not be coerced to {vtype} (value: {original})") + raise TemplateVariableError(f"Variable '{name}' could not be coerced to {vtype} (value: {original})") # Enum validation enum = conf.get('enum') if enum is not None and coerced not in enum: - raise ValueError(f"Variable '{name}' must be one of {enum}, got: {coerced}") + raise self._enum_value_error(name, coerced, enum) # Regex validation (only for strings) pattern = conf.get('regex') or conf.get('pattern') @@ -255,3 +266,14 @@ def _as_num(x): raise ValueError(f"Variable '{name}' must be <= {maxv}, got {coerced}") return coerced + + def _enum_value_error(self, name, value, enum): + allowed_values = ", ".join(str(item) for item in enum) + if value is None or value == "": + return TemplateVariableError( + f"Variable '{name}' must be set to one of: {allowed_values}. " + f"No value was provided. Pass --vars {name}= or define a default." + ) + return TemplateVariableError( + f"Variable '{name}' must be one of: {allowed_values}. Got: {value}." + ) diff --git a/tests/test_commands_more.py b/tests/test_commands_more.py index 8aee7b2..12399c3 100644 --- a/tests/test_commands_more.py +++ b/tests/test_commands_more.py @@ -9,6 +9,7 @@ from structkit.commands.list import ListCommand from structkit.commands.mcp import MCPCommand from structkit.commands.validate import ValidateCommand +from structkit.template_renderer import TemplateVariableError @pytest.fixture @@ -114,6 +115,25 @@ def test_generate_mappings_file_not_found(parser, tmp_path): command.execute(args) +def test_generate_reports_template_variable_error_without_traceback(parser, tmp_path, caplog): + command = GenerateCommand(parser) + args = parser.parse_args(['struct-x', str(tmp_path)]) + args.structures_path = None + args.mappings_file = None + args.backup = None + + with patch.object(command, '_load_yaml_config', return_value={'files': [], 'folders': []}), \ + patch.object(command, '_create_structure', side_effect=TemplateVariableError( + "Variable 'environment' must be one of: dev, staging, prod. Got: qa." + )): + with pytest.raises(SystemExit) as excinfo: + command.execute(args) + + assert excinfo.value.code == 1 + assert "Variable 'environment' must be one of: dev, staging, prod. Got: qa." in caplog.text + assert "Traceback" not in caplog.text + + def test_info_nonexistent_file_logs_error(parser): command = InfoCommand(parser) args = parser.parse_args(['does-not-exist']) diff --git a/tests/test_template_renderer.py b/tests/test_template_renderer.py index 54f5ae5..5bbf25a 100644 --- a/tests/test_template_renderer.py +++ b/tests/test_template_renderer.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import patch, MagicMock -from structkit.template_renderer import TemplateRenderer +from structkit.template_renderer import TemplateRenderer, TemplateVariableError @pytest.fixture def renderer(): @@ -204,3 +204,45 @@ def test_variable_icon_selection(): assert renderer._get_variable_icon("version", "string") == "🏷️" assert renderer._get_variable_icon("config_path", "string") == "📁" assert renderer._get_variable_icon("random_var", "string") == "🔧" + + +def test_enum_missing_value_has_clean_error(tmp_path): + config_variables = [ + {"environment": { + "type": "string", + "description": "Target deployment environment", + "enum": ["dev", "staging", "prod"], + }} + ] + renderer = TemplateRenderer( + config_variables, + str(tmp_path / "input.json"), + non_interactive=True, + ) + + with pytest.raises(TemplateVariableError) as excinfo: + renderer.prompt_for_missing_vars("{{@ environment @}}", {}) + + message = str(excinfo.value) + assert "Variable 'environment' must be set to one of: dev, staging, prod" in message + assert "No value was provided" in message + assert "--vars environment=" in message + + +def test_enum_invalid_value_has_clean_error(tmp_path): + config_variables = [ + {"environment": { + "type": "string", + "enum": ["dev", "staging", "prod"], + }} + ] + renderer = TemplateRenderer( + config_variables, + str(tmp_path / "input.json"), + non_interactive=True, + ) + + with pytest.raises(TemplateVariableError) as excinfo: + renderer.prompt_for_missing_vars("{{@ environment @}}", {"environment": "qa"}) + + assert str(excinfo.value) == "Variable 'environment' must be one of: dev, staging, prod. Got: qa."