Skip to content

Commit a409dac

Browse files
committed
fix(core): roll back entity.py when the generated model cannot import
- ast.parse only proves syntax, not that the module imports - restore the previous file content if importlib.reload raises - reload again after the rollback so the module works in memory - re-raise the original import error instead of masking it
1 parent 2f68c91 commit a409dac

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

src/osw/core.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,43 @@ def ensure_valid_python_source(content: str, path: str) -> None:
162162
raise SyntaxError(message) from e
163163

164164

165+
def reload_module_or_restore(module, path: str, previous_content: str = None) -> None:
166+
"""Reloads module, putting previous_content back if the import fails
167+
168+
ast.parse only proves the generated model is syntactically valid. It can
169+
still fail at import time, e.g. on an undefined name or an error raised
170+
while a class body is executed. Restoring the previous file content keeps
171+
later imports of osw.model.entity working instead of leaving a module on
172+
disk that raises for the rest of the installation's lifetime.
173+
"""
174+
try:
175+
importlib.reload(module)
176+
except Exception as e:
177+
_logger.error(f"Generated model at '{path}' failed to import: {e}")
178+
if previous_content is not None:
179+
_logger.error(f"Restoring the previous content of '{path}'")
180+
with open(path, "w", encoding="utf-8") as f:
181+
f.write(previous_content)
182+
try:
183+
importlib.reload(module)
184+
except Exception as restore_error:
185+
# do not mask the original failure, but make it obvious that
186+
# the module is now broken in memory as well
187+
_logger.error(
188+
f"Restoring '{path}' did not make it importable again: "
189+
f"{restore_error}"
190+
)
191+
raise
192+
193+
194+
def read_file_if_exists(path: str) -> str:
195+
"""Returns the content of path, or None if it does not exist yet"""
196+
if not os.path.exists(path):
197+
return None
198+
with open(path, encoding="utf-8") as f:
199+
return f.read()
200+
201+
165202
# Reusable type definitions
166203
class OverwriteOptions(Enum):
167204
"""Options for overwriting properties"""
@@ -1112,11 +1149,17 @@ def _fetch_schema(
11121149
# place is strictly better than writing invalid syntax (#125)
11131150
ensure_valid_python_source(content, result_model_path)
11141151

1152+
# keep the current file so that a model that parses but does not
1153+
# import can be rolled back below (#125)
1154+
previous_content = read_file_if_exists(result_model_path)
1155+
11151156
with open(result_model_path, "w", encoding="utf-8") as f:
11161157
f.write(content)
11171158

11181159
if fetchSchemaParam.final:
1119-
importlib.reload(model) # reload the updated module
1160+
# reload the updated module, restoring the previous content if
1161+
# the generated model turns out not to be importable
1162+
reload_module_or_restore(model, result_model_path, previous_content)
11201163
if not site_cache_state:
11211164
self.site.disable_cache() # restore original state
11221165

tests/test_fetch_schema_write_safety.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,30 @@
1414
"""
1515

1616
import ast
17+
import importlib
18+
import sys
1719

1820
import pytest
1921

20-
from osw.core import ensure_valid_python_source, remove_unserializable_default_sentinels
22+
from osw.core import (
23+
ensure_valid_python_source,
24+
reload_module_or_restore,
25+
remove_unserializable_default_sentinels,
26+
)
27+
28+
29+
@pytest.fixture
30+
def throwaway_module(tmp_path):
31+
"""An importable module on disk, cleaned out of sys.modules afterwards"""
32+
name = "osw_test_throwaway_model"
33+
path = tmp_path / f"{name}.py"
34+
path.write_text("VALUE = 1\n", encoding="utf-8")
35+
sys.path.insert(0, str(tmp_path))
36+
try:
37+
yield importlib.import_module(name), path
38+
finally:
39+
sys.path.remove(str(tmp_path))
40+
sys.modules.pop(name, None)
2141

2242

2343
def test_sentinel_default_is_rewritten_to_valid_python():
@@ -95,3 +115,43 @@ def test_validation_failure_leaves_an_existing_target_untouched(tmp_path):
95115
target.write_text("corrupted", encoding="utf-8") # never reached
96116

97117
assert target.read_text(encoding="utf-8") == "previous_valid_content = 1\n"
118+
119+
120+
def test_reload_restores_previous_content_when_the_new_model_cannot_import(
121+
throwaway_module,
122+
):
123+
"""Syntactically valid content can still fail at import time. The file must
124+
be rolled back so later imports keep working.
125+
"""
126+
module, path = throwaway_module
127+
previous_content = path.read_text(encoding="utf-8")
128+
broken = "raise RuntimeError('not importable')\n"
129+
ast.parse(broken) # passes the syntax guard, so only the import catches it
130+
path.write_text(broken, encoding="utf-8")
131+
132+
with pytest.raises(RuntimeError):
133+
reload_module_or_restore(module, str(path), previous_content)
134+
135+
assert path.read_text(encoding="utf-8") == previous_content
136+
assert module.VALUE == 1 # the in-memory module works again too
137+
138+
139+
def test_reload_keeps_the_new_model_when_it_imports(throwaway_module):
140+
module, path = throwaway_module
141+
# the length has to differ from the original source, otherwise the pyc
142+
# cache (keyed on mtime and size) can survive the reload
143+
path.write_text("VALUE = 222\n", encoding="utf-8")
144+
145+
reload_module_or_restore(module, str(path), "VALUE = 1\n")
146+
147+
assert path.read_text(encoding="utf-8") == "VALUE = 222\n"
148+
assert module.VALUE == 222
149+
150+
151+
def test_reload_without_previous_content_still_raises(throwaway_module):
152+
"""First-ever write has nothing to roll back to, but must not fail silently."""
153+
module, path = throwaway_module
154+
path.write_text("raise RuntimeError('not importable')\n", encoding="utf-8")
155+
156+
with pytest.raises(RuntimeError):
157+
reload_module_or_restore(module, str(path), None)

0 commit comments

Comments
 (0)