diff --git a/structkit/commands/generate.py b/structkit/commands/generate.py index 00729fe..3e07137 100644 --- a/structkit/commands/generate.py +++ b/structkit/commands/generate.py @@ -2,7 +2,7 @@ import os import yaml import argparse -from structkit.file_item import FileItem +from structkit.file_item import FileItem, ContentFetchError from structkit.completers import file_strategy_completer, structures_completer from structkit.template_renderer import TemplateRenderer, TemplateVariableError from structkit.sources import SourceError, resolve_structures_path @@ -207,7 +207,7 @@ def execute(self, args): # Actually generate structure try: self._create_structure(args, mappings) - except (GenerateConfigError, TemplateVariableError) as exc: + except (GenerateConfigError, TemplateVariableError, ContentFetchError) as exc: self.logger.error(f"❗ {exc}") raise SystemExit(1) from None diff --git a/structkit/file_item.py b/structkit/file_item.py index 87cecd1..64fa17b 100644 --- a/structkit/file_item.py +++ b/structkit/file_item.py @@ -11,6 +11,10 @@ load_dotenv() +class ContentFetchError(Exception): + """Raised when remote or local file: content cannot be fetched.""" + + class FileItem: def __init__(self, properties): self.logger = logging.getLogger(__name__) @@ -89,7 +93,9 @@ def fetch_content(self): raw_content, template_vars) self.logger.debug(f"Rendered content: {self.content}") except Exception as e: - self.logger.error(f"❗ Failed to fetch content from {self.content_location}: {e}") + raise ContentFetchError( + f"Failed to fetch content from {self.content_location}: {e}" + ) from e def _merge_default_template_vars(self, template_vars): default_vars = { diff --git a/tests/test_commands_more.py b/tests/test_commands_more.py index 2109e0b..9a370e4 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.file_item import ContentFetchError from structkit.template_renderer import TemplateVariableError @@ -341,3 +342,74 @@ def test_validate_variables_config_errors(parser): v._validate_variables_config([{ 'name': { 'type': 'bad' } }]) with pytest.raises(ValueError): v._validate_variables_config([{ 'name': { 'type': 'boolean', 'default': 'yes' } }]) + + +def test_generate_missing_local_file_ref_exits_cleanly(parser, tmp_path, caplog): + """Missing file:// target exits 1 with root-cause message and no Traceback.""" + command = GenerateCommand(parser) + out_dir = tmp_path / 'out' + out_dir.mkdir() + + struct_yaml = tmp_path / 'struct.yaml' + struct_yaml.write_text( + 'files:\n' + ' - out.txt:\n' + ' file: file:///tmp/does-not-exist-structkit-test.txt\n' + ) + + args = parser.parse_args(['--non-interactive', str(struct_yaml), str(out_dir)]) + + with pytest.raises(SystemExit) as excinfo: + command.execute(args) + + assert excinfo.value.code == 1 + assert 'Failed to fetch content from' in caplog.text + assert 'does-not-exist-structkit-test.txt' in caplog.text + assert 'Traceback' not in caplog.text + # The output file must NOT have been created + assert not (out_dir / 'out.txt').exists() + + +def test_generate_remote_fetch_failure_exits_cleanly(parser, tmp_path, caplog): + """Mocked remote fetch failure exits 1 with root-cause message and no Traceback.""" + command = GenerateCommand(parser) + out_dir = tmp_path / 'out' + out_dir.mkdir() + + config = { + 'files': [{'out.txt': {'file': 'https://example.com/no-such-file.txt'}}], + 'folders': [], + } + + store_dir = tmp_path / 'store' + store_dir.mkdir(parents=True, exist_ok=True) + (store_dir / 'input.json').write_text('{}') + + with patch.object(command, '_load_yaml_config', return_value=config), \ + patch( + 'structkit.content_fetcher.ContentFetcher._fetch_http_url', + side_effect=ConnectionError('network unreachable'), + ): + args = argparse.Namespace( + structure_definition='dummy', + base_path=str(out_dir), + structures_path=None, + dry_run=False, + diff=False, + output='file', + vars=None, + backup=None, + file_strategy='overwrite', + global_system_prompt=None, + input_store=str(store_dir / 'input.json'), + non_interactive=True, + mappings_file=None, + source=None, + ) + with pytest.raises(SystemExit) as excinfo: + command.execute(args) + + assert excinfo.value.code == 1 + assert 'Failed to fetch content from' in caplog.text + assert 'Traceback' not in caplog.text + assert not (out_dir / 'out.txt').exists()