Skip to content

Commit ceeb44c

Browse files
authored
[#165] Abort cleanly when remote or local file: content cannot be fetched (#170)
## Summary Fixes #165. When a `file:` item referenced content that could not be fetched, the error was logged but silently suppressed. Generation then continued with `self.content` unset, causing a secondary Jinja2 `TypeError` traceback that hid the original root cause. ## Changes ### `structkit/file_item.py` - Introduced `ContentFetchError` — a clean domain exception for content fetch failures. - `FileItem.fetch_content()` now raises `ContentFetchError` instead of logging and suppressing the exception. ### `structkit/commands/generate.py` - Imported `ContentFetchError` from `file_item`. - `GenerateCommand.execute()` catches `ContentFetchError` alongside `GenerateConfigError` and `TemplateVariableError`, logging only the root-cause message and exiting with code 1. ### `tests/test_commands_more.py` - Added `test_generate_missing_local_file_ref_exits_cleanly`: verifies a missing `file://` target exits 1 with a clean root-cause message, no `Traceback`, and no output file created. - Added `test_generate_remote_fetch_failure_exits_cleanly`: verifies a mocked remote HTTP fetch failure exits 1 cleanly with the same guarantees. ## Acceptance criteria - [x] Missing `file://` target exits 1 with a clean root-cause message and no `Traceback`. - [x] Remote fetch failures (HTTP/GitHub/S3/GCS) report source and error category cleanly. - [x] Generation stops immediately on required fetch failures. - [x] Tests cover missing local `file://` and one mocked remote failure.
1 parent 2193969 commit ceeb44c

3 files changed

Lines changed: 81 additions & 3 deletions

File tree

structkit/commands/generate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import yaml
44
import argparse
5-
from structkit.file_item import FileItem
5+
from structkit.file_item import FileItem, ContentFetchError
66
from structkit.completers import file_strategy_completer, structures_completer
77
from structkit.template_renderer import TemplateRenderer, TemplateVariableError
88
from structkit.sources import SourceError, resolve_structures_path
@@ -207,7 +207,7 @@ def execute(self, args):
207207
# Actually generate structure
208208
try:
209209
self._create_structure(args, mappings)
210-
except (GenerateConfigError, TemplateVariableError) as exc:
210+
except (GenerateConfigError, TemplateVariableError, ContentFetchError) as exc:
211211
self.logger.error(f"❗ {exc}")
212212
raise SystemExit(1) from None
213213

structkit/file_item.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
load_dotenv()
1212

1313

14+
class ContentFetchError(Exception):
15+
"""Raised when remote or local file: content cannot be fetched."""
16+
17+
1418
class FileItem:
1519
def __init__(self, properties):
1620
self.logger = logging.getLogger(__name__)
@@ -89,7 +93,9 @@ def fetch_content(self):
8993
raw_content, template_vars)
9094
self.logger.debug(f"Rendered content: {self.content}")
9195
except Exception as e:
92-
self.logger.error(f"❗ Failed to fetch content from {self.content_location}: {e}")
96+
raise ContentFetchError(
97+
f"Failed to fetch content from {self.content_location}: {e}"
98+
) from e
9399

94100
def _merge_default_template_vars(self, template_vars):
95101
default_vars = {

tests/test_commands_more.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from structkit.commands.list import ListCommand
1010
from structkit.commands.mcp import MCPCommand
1111
from structkit.commands.validate import ValidateCommand
12+
from structkit.file_item import ContentFetchError
1213
from structkit.template_renderer import TemplateVariableError
1314

1415

@@ -341,3 +342,74 @@ def test_validate_variables_config_errors(parser):
341342
v._validate_variables_config([{ 'name': { 'type': 'bad' } }])
342343
with pytest.raises(ValueError):
343344
v._validate_variables_config([{ 'name': { 'type': 'boolean', 'default': 'yes' } }])
345+
346+
347+
def test_generate_missing_local_file_ref_exits_cleanly(parser, tmp_path, caplog):
348+
"""Missing file:// target exits 1 with root-cause message and no Traceback."""
349+
command = GenerateCommand(parser)
350+
out_dir = tmp_path / 'out'
351+
out_dir.mkdir()
352+
353+
struct_yaml = tmp_path / 'struct.yaml'
354+
struct_yaml.write_text(
355+
'files:\n'
356+
' - out.txt:\n'
357+
' file: file:///tmp/does-not-exist-structkit-test.txt\n'
358+
)
359+
360+
args = parser.parse_args(['--non-interactive', str(struct_yaml), str(out_dir)])
361+
362+
with pytest.raises(SystemExit) as excinfo:
363+
command.execute(args)
364+
365+
assert excinfo.value.code == 1
366+
assert 'Failed to fetch content from' in caplog.text
367+
assert 'does-not-exist-structkit-test.txt' in caplog.text
368+
assert 'Traceback' not in caplog.text
369+
# The output file must NOT have been created
370+
assert not (out_dir / 'out.txt').exists()
371+
372+
373+
def test_generate_remote_fetch_failure_exits_cleanly(parser, tmp_path, caplog):
374+
"""Mocked remote fetch failure exits 1 with root-cause message and no Traceback."""
375+
command = GenerateCommand(parser)
376+
out_dir = tmp_path / 'out'
377+
out_dir.mkdir()
378+
379+
config = {
380+
'files': [{'out.txt': {'file': 'https://example.com/no-such-file.txt'}}],
381+
'folders': [],
382+
}
383+
384+
store_dir = tmp_path / 'store'
385+
store_dir.mkdir(parents=True, exist_ok=True)
386+
(store_dir / 'input.json').write_text('{}')
387+
388+
with patch.object(command, '_load_yaml_config', return_value=config), \
389+
patch(
390+
'structkit.content_fetcher.ContentFetcher._fetch_http_url',
391+
side_effect=ConnectionError('network unreachable'),
392+
):
393+
args = argparse.Namespace(
394+
structure_definition='dummy',
395+
base_path=str(out_dir),
396+
structures_path=None,
397+
dry_run=False,
398+
diff=False,
399+
output='file',
400+
vars=None,
401+
backup=None,
402+
file_strategy='overwrite',
403+
global_system_prompt=None,
404+
input_store=str(store_dir / 'input.json'),
405+
non_interactive=True,
406+
mappings_file=None,
407+
source=None,
408+
)
409+
with pytest.raises(SystemExit) as excinfo:
410+
command.execute(args)
411+
412+
assert excinfo.value.code == 1
413+
assert 'Failed to fetch content from' in caplog.text
414+
assert 'Traceback' not in caplog.text
415+
assert not (out_dir / 'out.txt').exists()

0 commit comments

Comments
 (0)