Skip to content

Commit 54c5027

Browse files
authored
[#166] Make --input-store errors user-friendly, including relative paths and corrupt JSON (#171)
## Summary Fixes #166. Using `--input-store input.json` (a bare filename with no directory component) caused `os.makedirs('')` to raise a `FileNotFoundError` before generation even started. Additionally, corrupt JSON or permission errors in the input-store file surfaced as raw Python tracebacks instead of user-friendly messages. ## Changes ### `structkit/input_store.py` - Introduced `InputStoreError` — a clean domain exception for input-store failures. - Fixed `InputStore.__init__`: `os.makedirs` is now only called when the directory component is non-empty, so bare filenames like `input.json` resolve to the current directory without crashing. - Wrapped file-creation, `json.load`, and `json.dump` calls to convert `OSError` and `json.JSONDecodeError` into `InputStoreError` with a message that includes the file path. ### `structkit/commands/generate.py` - Imported `InputStoreError` from `structkit.input_store`. - `GenerateCommand.execute()` catches `InputStoreError` alongside `GenerateConfigError`, `TemplateVariableError`, and `ContentFetchError`, logging only the root-cause message and exiting with code 1. ### `tests/test_commands_more.py` - Added `test_input_store_relative_path_does_not_crash`: verifies `InputStore('input.json')` creates and reads `./input.json` without error. - Added `test_generate_corrupt_input_store_exits_cleanly`: verifies corrupt JSON exits 1 with a clean message and no `Traceback`. - Added `test_generate_unreadable_input_store_exits_cleanly`: verifies an `OSError` reading the store exits 1 cleanly. ## Acceptance criteria - [x] `--input-store input.json` works and creates/uses `./input.json`. - [x] Corrupt input-store JSON exits 1 with a clean message and no `Traceback`. - [x] Permission/read/write errors for the input store include the path and exit 1 cleanly. - [x] Tests cover relative path, corrupt JSON, and read-error path.
1 parent ceeb44c commit 54c5027

3 files changed

Lines changed: 108 additions & 10 deletions

File tree

structkit/commands/generate.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
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
9+
from structkit.input_store import InputStoreError
910

1011
import subprocess
1112

@@ -207,7 +208,7 @@ def execute(self, args):
207208
# Actually generate structure
208209
try:
209210
self._create_structure(args, mappings)
210-
except (GenerateConfigError, TemplateVariableError, ContentFetchError) as exc:
211+
except (GenerateConfigError, TemplateVariableError, ContentFetchError, InputStoreError) as exc:
211212
self.logger.error(f"❗ {exc}")
212213
raise SystemExit(1) from None
213214

structkit/input_store.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,48 @@
22
import os
33

44

5+
class InputStoreError(Exception):
6+
"""Raised when the input store cannot be read, written, or parsed."""
7+
8+
59
class InputStore:
610

711
def __init__(self, input_file):
812
self.input_file = input_file
913
self.data = None
1014

11-
# create directory if it doesn't exist
15+
# create directory if it doesn't exist (skip for bare filenames like 'input.json')
1216
directory = os.path.dirname(input_file)
13-
if not os.path.exists(directory):
14-
os.makedirs(directory)
17+
if directory:
18+
try:
19+
os.makedirs(directory, exist_ok=True)
20+
except OSError as e:
21+
raise InputStoreError(
22+
f"Cannot create input-store directory '{directory}': {e}"
23+
) from e
1524

1625
# create file if it doesn't exist
1726
if not os.path.exists(input_file):
18-
with open(input_file, 'w') as f:
19-
json.dump({}, f)
27+
try:
28+
with open(input_file, 'w') as f:
29+
json.dump({}, f)
30+
except OSError as e:
31+
raise InputStoreError(
32+
f"Cannot create input-store file '{input_file}': {e}"
33+
) from e
2034

2135
def load(self):
22-
with open(self.input_file, 'r') as f:
23-
self.data = json.load(f)
36+
try:
37+
with open(self.input_file, 'r') as f:
38+
self.data = json.load(f)
39+
except OSError as e:
40+
raise InputStoreError(
41+
f"Cannot read input-store file '{self.input_file}': {e}"
42+
) from e
43+
except json.JSONDecodeError as e:
44+
raise InputStoreError(
45+
f"Input-store file '{self.input_file}' contains invalid JSON: {e}"
46+
) from e
2447

2548
def get_data(self):
2649
return self.data
@@ -32,5 +55,10 @@ def set_value(self, key, value):
3255
self.data[key] = value
3356

3457
def save(self):
35-
with open(self.input_file, 'w') as f:
36-
json.dump(self.data, f, indent=2)
58+
try:
59+
with open(self.input_file, 'w') as f:
60+
json.dump(self.data, f, indent=2)
61+
except OSError as e:
62+
raise InputStoreError(
63+
f"Cannot write input-store file '{self.input_file}': {e}"
64+
) from e

tests/test_commands_more.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from structkit.commands.mcp import MCPCommand
1111
from structkit.commands.validate import ValidateCommand
1212
from structkit.file_item import ContentFetchError
13+
from structkit.input_store import InputStoreError
1314
from structkit.template_renderer import TemplateVariableError
1415

1516

@@ -413,3 +414,71 @@ def test_generate_remote_fetch_failure_exits_cleanly(parser, tmp_path, caplog):
413414
assert 'Failed to fetch content from' in caplog.text
414415
assert 'Traceback' not in caplog.text
415416
assert not (out_dir / 'out.txt').exists()
417+
418+
419+
# ---------------------------------------------------------------------------
420+
# InputStore tests
421+
# ---------------------------------------------------------------------------
422+
423+
def test_input_store_relative_path_does_not_crash(tmp_path):
424+
"""A bare filename like 'input.json' must not cause makedirs('') crash."""
425+
import os
426+
from structkit.input_store import InputStore
427+
428+
orig = os.getcwd()
429+
try:
430+
os.chdir(tmp_path)
431+
store = InputStore('input.json')
432+
store.load()
433+
assert store.get_data() == {}
434+
store.set_value('key', 'value')
435+
store.save()
436+
store2 = InputStore('input.json')
437+
store2.load()
438+
assert store2.get_data() == {'key': 'value'}
439+
finally:
440+
os.chdir(orig)
441+
442+
443+
def test_generate_corrupt_input_store_exits_cleanly(parser, tmp_path, caplog):
444+
"""Corrupt JSON in the input-store exits 1 with a clean message and no Traceback."""
445+
command = GenerateCommand(parser)
446+
out_dir = tmp_path / 'out'
447+
out_dir.mkdir()
448+
449+
# Write corrupt JSON
450+
store_file = tmp_path / 'input.json'
451+
store_file.write_text('{ not valid json')
452+
453+
struct_yaml = tmp_path / 'struct.yaml'
454+
struct_yaml.write_text('files:\n - hello.txt: Hello\n')
455+
456+
args = parser.parse_args(['--non-interactive', str(struct_yaml), str(out_dir)])
457+
args.input_store = str(store_file)
458+
459+
with pytest.raises(SystemExit) as excinfo:
460+
command.execute(args)
461+
462+
assert excinfo.value.code == 1
463+
assert 'invalid JSON' in caplog.text
464+
assert 'Traceback' not in caplog.text
465+
466+
467+
def test_generate_unreadable_input_store_exits_cleanly(parser, tmp_path, caplog):
468+
"""An OSError reading the input store exits 1 with a clean message and no Traceback."""
469+
command = GenerateCommand(parser)
470+
out_dir = tmp_path / 'out'
471+
out_dir.mkdir()
472+
473+
struct_yaml = tmp_path / 'struct.yaml'
474+
struct_yaml.write_text('files:\n - hello.txt: Hello\n')
475+
476+
args = parser.parse_args(['--non-interactive', str(struct_yaml), str(out_dir)])
477+
args.input_store = str(tmp_path / 'input.json')
478+
479+
with patch('builtins.open', side_effect=PermissionError('permission denied')):
480+
with pytest.raises(SystemExit) as excinfo:
481+
command.execute(args)
482+
483+
assert excinfo.value.code == 1
484+
assert 'Traceback' not in caplog.text

0 commit comments

Comments
 (0)