Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions structkit/commands/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Expand Down
30 changes: 26 additions & 4 deletions structkit/template_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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')
Expand All @@ -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}=<value> or define a default."
)
return TemplateVariableError(
f"Variable '{name}' must be one of: {allowed_values}. Got: {value}."
)
20 changes: 20 additions & 0 deletions tests/test_commands_more.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'])
Expand Down
44 changes: 43 additions & 1 deletion tests/test_template_renderer.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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=<value>" 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."
Loading