Skip to content

Commit 014073c

Browse files
committed
fix(cli): preserve configurations when confirmed saves fail
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
1 parent 6b1dfde commit 014073c

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

‎ifixai/cli/config_file.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import yaml
88
from pydantic import BaseModel, ValidationError
99

10+
from ifixai.cli.reports import _write_report_atomic
11+
1012
CONFIG_FILENAME = "ifixai.yaml"
1113

1214

@@ -66,7 +68,8 @@ def load_config(start_dir: Path | None = None) -> RunConfig | None:
6668

6769

6870
def write_config(config: RunConfig, start_dir: Path | None = None) -> Path:
69-
"""Write ``config`` to ``ifixai.yaml`` and return the path."""
71+
"""Atomically save ``config`` to ``ifixai.yaml`` and return its requested path."""
7072
path = config_path(start_dir)
71-
path.write_text(config.to_yaml(), encoding="utf-8")
73+
# Keep linked configurations linked while replacing their resolved target.
74+
_write_report_atomic(path.resolve(), config.to_yaml())
7275
return path
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Failed wizard saves must retain the previous reusable configuration."""
2+
3+
import errno
4+
import json
5+
import os
6+
import stat
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
from ifixai.cli.config_file import RunConfig, load_config, write_config
14+
15+
_LIMITED_SAVE = '''
16+
import json, resource, signal, sys
17+
from pathlib import Path
18+
from ifixai.cli.config_file import RunConfig, write_config
19+
config = RunConfig(provider="mock", fixture="default", name="new configuration " * 30)
20+
signal.signal(signal.SIGXFSZ, signal.SIG_IGN)
21+
resource.setrlimit(resource.RLIMIT_FSIZE, (128, 128))
22+
try:
23+
write_config(config, Path(sys.argv[1]))
24+
except OSError as exc:
25+
print(json.dumps({"errno": exc.errno}))
26+
else:
27+
print(json.dumps({"errno": None}))
28+
'''
29+
30+
31+
def _config_paths(tmp_path, linked):
32+
project = tmp_path / "project"
33+
project.mkdir()
34+
requested = project / "ifixai.yaml"
35+
if not linked:
36+
return project, requested, requested
37+
target = tmp_path / "shared" / "real.yaml"
38+
target.parent.mkdir()
39+
intermediate = tmp_path / "shared" / "linked.yaml"
40+
try:
41+
intermediate.symlink_to(target.name)
42+
requested.symlink_to(intermediate)
43+
except OSError:
44+
pytest.skip("This host does not allow fixture symlinks")
45+
return project, requested, target
46+
47+
48+
@pytest.mark.parametrize("linked", [False, True])
49+
def test_native_failed_save_retains_config_and_can_retry(tmp_path, linked):
50+
pytest.importorskip("resource")
51+
project, requested, target = _config_paths(tmp_path, linked)
52+
previous = RunConfig(provider="mock", fixture="default", name="previous")
53+
original = previous.to_yaml().encode()
54+
target.write_bytes(original)
55+
target.chmod(0o640)
56+
env = os.environ.copy()
57+
env["PYTHONDONTWRITEBYTECODE"] = "1"
58+
env["PYTHONPATH"] = os.pathsep.join(
59+
[str(Path(__file__).parents[2]), env.get("PYTHONPATH", "")]
60+
)
61+
result = subprocess.run(
62+
[sys.executable, "-c", _LIMITED_SAVE, str(project)],
63+
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30, check=True,
64+
)
65+
assert json.loads(result.stdout)["errno"] == errno.EFBIG
66+
assert target.read_bytes() == original
67+
if os.name == "posix":
68+
assert stat.S_IMODE(target.stat().st_mode) == 0o640
69+
assert not list(tmp_path.rglob(".ifixai-report-*.tmp"))
70+
if linked:
71+
assert requested.is_symlink()
72+
replacement = RunConfig(provider="mock", fixture="default", name="retry")
73+
assert write_config(replacement, project) == requested
74+
assert load_config(project) == replacement
75+
if os.name == "posix":
76+
assert stat.S_IMODE(target.stat().st_mode) == 0o640
77+
if linked:
78+
assert requested.is_symlink()
79+
80+
81+
@pytest.mark.parametrize("linked", [False, True])
82+
def test_successful_save_keeps_requested_path_and_target_mode(tmp_path, linked):
83+
project, requested, target = _config_paths(tmp_path, linked)
84+
target.write_text("provider: mock\n", encoding="utf-8")
85+
target.chmod(0o600)
86+
config = RunConfig(provider="mock", fixture="default", name="saved")
87+
assert write_config(config, project) == requested
88+
assert load_config(project) == config
89+
if os.name == "posix":
90+
assert stat.S_IMODE(target.stat().st_mode) == 0o600
91+
if linked:
92+
assert requested.is_symlink()
93+
94+
95+
@pytest.mark.skipif(os.name != "posix", reason="POSIX umask and mode bits")
96+
def test_new_config_keeps_normal_creation_umask(tmp_path):
97+
script = '''
98+
import os, sys
99+
from pathlib import Path
100+
from ifixai.cli.config_file import RunConfig, write_config
101+
os.umask(0o077)
102+
write_config(RunConfig(provider="mock"), Path(sys.argv[1]))
103+
'''
104+
env = os.environ.copy()
105+
env["PYTHONDONTWRITEBYTECODE"] = "1"
106+
env["PYTHONPATH"] = os.pathsep.join(
107+
[str(Path(__file__).parents[2]), env.get("PYTHONPATH", "")]
108+
)
109+
subprocess.run(
110+
[sys.executable, "-c", script, str(tmp_path)],
111+
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30, check=True,
112+
)
113+
assert stat.S_IMODE((tmp_path / "ifixai.yaml").stat().st_mode) == 0o600
114+
assert load_config(tmp_path) == RunConfig(provider="mock")

0 commit comments

Comments
 (0)