Skip to content

Commit 2193969

Browse files
authored
Handle generate config errors cleanly (#169)
1 parent 43a4a7c commit 2193969

3 files changed

Lines changed: 88 additions & 16 deletions

File tree

structkit/commands/generate.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
import subprocess
1111

1212

13+
class GenerateConfigError(ValueError):
14+
"""Expected generate config load/shape error shown without traceback."""
15+
16+
1317
# Generate command class
1418
class GenerateCommand(Command):
1519
def __init__(self, parser):
@@ -111,8 +115,7 @@ def _load_yaml_config(self, structure_definition, structures_path):
111115
structure_definition = f"file://{structure_definition}"
112116

113117
if structure_definition.startswith("file://") and structure_definition.endswith(".yaml"):
114-
with open(structure_definition[7:], 'r') as f:
115-
return yaml.safe_load(f)
118+
file_path = structure_definition[7:]
116119
else:
117120
this_file = os.path.dirname(os.path.realpath(__file__))
118121
contribs_path = os.path.join(this_file, "..", "contribs")
@@ -121,11 +124,23 @@ def _load_yaml_config(self, structure_definition, structures_path):
121124
file_path = os.path.join(structures_path, f"{structure_definition}.yaml")
122125
if not os.path.exists(file_path):
123126
file_path = os.path.join(contribs_path, f"{structure_definition}.yaml")
124-
if not os.path.exists(file_path):
125-
self.logger.error(f"❗ File not found: {file_path}")
126-
return None
127+
128+
try:
127129
with open(file_path, 'r') as f:
128130
return yaml.safe_load(f)
131+
except FileNotFoundError:
132+
raise GenerateConfigError(f"File not found: {file_path}") from None
133+
except yaml.YAMLError as exc:
134+
raise GenerateConfigError(f"Invalid YAML in {file_path}: {exc}") from None
135+
except OSError as exc:
136+
raise GenerateConfigError(f"Failed to read {file_path}: {exc}") from None
137+
138+
def _validate_loaded_config(self, config):
139+
if config is None:
140+
return {}
141+
if not isinstance(config, dict):
142+
raise GenerateConfigError("Top-level YAML content must be a mapping.")
143+
return config
129144

130145
def execute(self, args):
131146
try:
@@ -164,19 +179,23 @@ def execute(self, args):
164179
self.logger.error(f"Mappings file not found: {mappings_file_path}")
165180
return
166181

182+
# Load and validate config before creating output/backup paths, so config
183+
# errors do not leave partial filesystem side effects behind.
184+
try:
185+
config = self._validate_loaded_config(
186+
self._load_yaml_config(args.structure_definition, args.structures_path)
187+
)
188+
except GenerateConfigError as exc:
189+
self.logger.error(f"❗ {exc}")
190+
raise SystemExit(1) from None
191+
167192
if args.backup and not os.path.exists(args.backup):
168193
os.makedirs(args.backup)
169194

170195
if args.base_path and not os.path.exists(args.base_path) and "console" not in args.output:
171196
self.logger.info(f"Creating base path: {args.base_path}")
172197
os.makedirs(args.base_path)
173198

174-
# Load config to check for hooks
175-
config = None
176-
config = self._load_yaml_config(args.structure_definition, args.structures_path)
177-
if config is None:
178-
return
179-
180199
pre_hooks = config.get('pre_hooks', [])
181200
post_hooks = config.get('post_hooks', [])
182201

@@ -188,7 +207,7 @@ def execute(self, args):
188207
# Actually generate structure
189208
try:
190209
self._create_structure(args, mappings)
191-
except TemplateVariableError as exc:
210+
except (GenerateConfigError, TemplateVariableError) as exc:
192211
self.logger.error(f"❗ {exc}")
193212
raise SystemExit(1) from None
194213

@@ -201,9 +220,9 @@ def _create_structure(self, args, mappings=None, summary=None, print_summary=Tru
201220
if isinstance(args, dict):
202221
args = argparse.Namespace(**args)
203222

204-
config = self._load_yaml_config(args.structure_definition, args.structures_path)
205-
if config is None:
206-
return summary if summary is not None else None
223+
config = self._validate_loaded_config(
224+
self._load_yaml_config(args.structure_definition, args.structures_path)
225+
)
207226

208227
# Safely parse template variables
209228
template_vars = self._parse_template_vars(args.vars) if getattr(args, 'vars', None) else {}

structkit/mcp_server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,19 @@ class Args:
178178
GenerateCommand(dummy_parser).execute(args)
179179
text = buf.getvalue()
180180
return text.strip() or "Structure generation completed successfully"
181+
except SystemExit as exc:
182+
text = buf.getvalue().strip()
183+
return text or f"Error: structure generation failed with exit code {exc.code}"
181184
finally:
182185
sys.stdout = old
183186
else:
184187
# Create a dummy parser for GenerateCommand
185188
import argparse
186189
dummy_parser = argparse.ArgumentParser()
187-
GenerateCommand(dummy_parser).execute(args)
190+
try:
191+
GenerateCommand(dummy_parser).execute(args)
192+
except SystemExit as exc:
193+
return f"Error: structure generation failed with exit code {exc.code}"
188194
if dry_run:
189195
return f"Dry run completed for structure '{structure_definition}' at '{base_path}'"
190196
return f"Structure '{structure_definition}' generated successfully at '{base_path}'"

tests/test_commands_more.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,53 @@ def test_generate_reports_template_variable_error_without_traceback(parser, tmp_
134134
assert "Traceback" not in caplog.text
135135

136136

137+
def test_generate_invalid_yaml_reports_clean_error_without_side_effect(parser, tmp_path, caplog):
138+
command = GenerateCommand(parser)
139+
invalid = tmp_path / 'invalid.yaml'
140+
invalid.write_text('files: [\n')
141+
out_dir = tmp_path / 'out-invalid'
142+
args = parser.parse_args(['--non-interactive', str(invalid), str(out_dir)])
143+
144+
with pytest.raises(SystemExit) as excinfo:
145+
command.execute(args)
146+
147+
assert excinfo.value.code == 1
148+
assert f"Invalid YAML in {invalid}" in caplog.text
149+
assert "Traceback" not in caplog.text
150+
assert not out_dir.exists()
151+
152+
153+
def test_generate_top_level_non_mapping_reports_clean_error_without_side_effect(parser, tmp_path, caplog):
154+
command = GenerateCommand(parser)
155+
non_mapping = tmp_path / 'list.yaml'
156+
non_mapping.write_text('- item\n')
157+
out_dir = tmp_path / 'out-list'
158+
args = parser.parse_args(['--non-interactive', str(non_mapping), str(out_dir)])
159+
160+
with pytest.raises(SystemExit) as excinfo:
161+
command.execute(args)
162+
163+
assert excinfo.value.code == 1
164+
assert "Top-level YAML content must be a mapping." in caplog.text
165+
assert "Traceback" not in caplog.text
166+
assert not out_dir.exists()
167+
168+
169+
def test_generate_missing_file_reports_clean_error_without_side_effect(parser, tmp_path, caplog):
170+
command = GenerateCommand(parser)
171+
missing = tmp_path / 'missing.yaml'
172+
out_dir = tmp_path / 'out-missing'
173+
args = parser.parse_args(['--non-interactive', str(missing), str(out_dir)])
174+
175+
with pytest.raises(SystemExit) as excinfo:
176+
command.execute(args)
177+
178+
assert excinfo.value.code == 1
179+
assert f"File not found: {missing}" in caplog.text
180+
assert "Traceback" not in caplog.text
181+
assert not out_dir.exists()
182+
183+
137184
def test_info_nonexistent_file_logs_error(parser):
138185
command = InfoCommand(parser)
139186
args = parser.parse_args(['does-not-exist'])

0 commit comments

Comments
 (0)