Skip to content

Commit 8a830d9

Browse files
authored
fix(core): never write unparseable entity.py from fetch_schema (#167)
- strip datamodel-code-generator sentinel defaults before writing - validate the generated source with ast.parse before opening the file - roll back the previous entity.py when the new model cannot import - log black/isort failures instead of discarding them
1 parent 23a1db1 commit 8a830d9

2 files changed

Lines changed: 264 additions & 3 deletions

File tree

‎src/osw/core.py‎

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import ast
34
import importlib
45
import json
56
import logging
@@ -113,6 +114,93 @@ def collect_messages(
113114
return target
114115

115116

117+
def remove_unserializable_default_sentinels(content: str) -> str:
118+
"""Replaces defaults that repr a datamodel-code-generator sentinel object
119+
120+
datamodel-code-generator uses a bare `UNDEFINED = object()` sentinel with
121+
`is` identity checks. oold's merge_deep deep-copies schema dicts during
122+
allOf composition, which clones that sentinel into a look-alike object,
123+
defeating the identity guard, so the generator reprs it into source as
124+
`default_factory=lambda :Foo.parse_obj(<object object at 0x...>)`, which
125+
is not valid Python.
126+
127+
Based on oold.generator.Generator.generate(), but matching the factory
128+
expression itself rather than everything up to the next closing paren:
129+
oold's pattern consumes the paren belonging to `parse_obj(`, which leaves
130+
a dangling `)` behind whenever the sentinel is wrapped in a call.
131+
"""
132+
return re.sub(
133+
r"default_factory=lambda\s*:\s*"
134+
r"(?:[\w.]+\(<object object at 0x[0-9a-fA-F]+>\)"
135+
r"|<object object at 0x[0-9a-fA-F]+>)",
136+
"default=None",
137+
content,
138+
)
139+
140+
141+
def ensure_valid_python_source(content: str, path: str) -> None:
142+
"""Raises a descriptive SyntaxError if content is not valid Python
143+
144+
_fetch_schema writes the generated model to a file that is imported right
145+
after (or, for non-final calls, on the next process start). A corrupt
146+
write poisons every later import of osw.model.entity, and there is
147+
previously-valid content sitting at `path` that would otherwise still
148+
work. Validating before opening the file for writing means a bad
149+
generation leaves that previous, valid content in place instead.
150+
"""
151+
try:
152+
ast.parse(content)
153+
except SyntaxError as e:
154+
offending_line = ""
155+
if e.lineno is not None:
156+
lines = content.splitlines()
157+
if 0 < e.lineno <= len(lines):
158+
offending_line = lines[e.lineno - 1]
159+
message = (
160+
f"Generated model for '{path}' is not valid Python: "
161+
f"{e.msg} (line {e.lineno}): {offending_line!r}"
162+
)
163+
_logger.error(message)
164+
raise SyntaxError(message) from e
165+
166+
167+
def reload_module_or_restore(module, path: str, previous_content: str = None) -> None:
168+
"""Reloads module, putting previous_content back if the import fails
169+
170+
ast.parse only proves the generated model is syntactically valid. It can
171+
still fail at import time, e.g. on an undefined name or an error raised
172+
while a class body is executed. Restoring the previous file content keeps
173+
later imports of osw.model.entity working instead of leaving a module on
174+
disk that raises for the rest of the installation's lifetime.
175+
"""
176+
try:
177+
importlib.reload(module)
178+
except Exception as e:
179+
_logger.error(f"Generated model at '{path}' failed to import: {e}")
180+
if previous_content is not None:
181+
_logger.error(f"Restoring the previous content of '{path}'")
182+
with open(path, "w", encoding="utf-8") as f:
183+
f.write(previous_content)
184+
try:
185+
importlib.reload(module)
186+
except Exception as restore_error:
187+
# do not mask the original failure, but make it obvious that
188+
# the module is now broken in memory as well
189+
_logger.error(
190+
f"Restoring '{path}' did not make it importable again: "
191+
f"{restore_error}"
192+
)
193+
raise
194+
195+
196+
def read_file_if_exists(path: str) -> str:
197+
"""Returns the content of path, or None if it does not exist yet"""
198+
if not os.path.exists(path):
199+
return None
200+
with open(path, encoding="utf-8") as f:
201+
return f.read()
202+
203+
116204
# Reusable type definitions
117205
class OverwriteOptions(Enum):
118206
"""Options for overwriting properties"""
@@ -845,6 +933,9 @@ def _fetch_schema(
845933
# are not v1 compatible mainly by using update_model()
846934
content = re.sub(r"(,?\s*unique_items=True\s*)", "", content)
847935

936+
# fix unserializable defaults from datamodel-code-generator (#125)
937+
content = remove_unserializable_default_sentinels(content)
938+
848939
# Detect empty subclasses, replaces their occurrences with base classes,
849940
# and removes the empty class definitions.
850941
# Only processes subclasses that follow naming patterns:
@@ -1053,14 +1144,27 @@ def _fetch_schema(
10531144
content = black.format_str(content, mode=black.Mode())
10541145
# run isort to sort imports using Vertical Hanging Indent style
10551146
content = isort.code(content, profile="black")
1056-
except Exception:
1057-
pass # black is optional, continue without formatting
1147+
except Exception as e:
1148+
# black/isort are optional, continue without formatting, but
1149+
# do not hide a signal that the generated content is broken
1150+
_logger.warning(f"Failed to format generated model content: {e}")
1151+
1152+
# validate before writing: a corrupt write poisons every later
1153+
# import of this file, so leaving the previous valid content in
1154+
# place is strictly better than writing invalid syntax (#125)
1155+
ensure_valid_python_source(content, result_model_path)
1156+
1157+
# keep the current file so that a model that parses but does not
1158+
# import can be rolled back below (#125)
1159+
previous_content = read_file_if_exists(result_model_path)
10581160

10591161
with open(result_model_path, "w", encoding="utf-8") as f:
10601162
f.write(content)
10611163

10621164
if fetchSchemaParam.final:
1063-
importlib.reload(model) # reload the updated module
1165+
# reload the updated module, restoring the previous content if
1166+
# the generated model turns out not to be importable
1167+
reload_module_or_restore(model, result_model_path, previous_content)
10641168
if not site_cache_state:
10651169
self.site.disable_cache() # restore original state
10661170

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Unit tests for the sentinel-cleanup and syntax-validation guards in osw.core.
2+
3+
Regression guard for #125: datamodel-code-generator uses a bare
4+
`UNDEFINED = object()` sentinel with `is` identity checks. oold's merge_deep
5+
deep-copies schema dicts during allOf composition, which clones that sentinel
6+
into a look-alike object, defeating the identity guard, so the generator
7+
repr's it into source (see oold.generator.Generator.generate() for oold's own
8+
workaround). _fetch_schema used to write that content straight to entity.py
9+
and reload it, raising SyntaxError and poisoning every later `import
10+
osw.core` in the process.
11+
12+
These tests exercise the extracted helpers directly, fully offline, and
13+
never call the real (wiki- and network-backed) `_fetch_schema`.
14+
"""
15+
16+
import ast
17+
import importlib
18+
import sys
19+
20+
import pytest
21+
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)
41+
42+
43+
def test_sentinel_default_is_rewritten_to_valid_python():
44+
"""A repr'd sentinel object breaks ast.parse until the substitution runs."""
45+
bad = (
46+
"risk_assessment: RiskAssessmentProcess | None = Field("
47+
"default_factory=lambda :<object object at 0x000001A2B3C4D5E6>)\n"
48+
)
49+
with pytest.raises(SyntaxError):
50+
ast.parse(bad)
51+
52+
fixed = remove_unserializable_default_sentinels(bad)
53+
54+
assert "<object object at" not in fixed
55+
ast.parse(fixed) # must not raise
56+
57+
58+
def test_sentinel_wrapped_in_a_parse_obj_call_is_rewritten():
59+
"""The shape actually reported in #125, where the sentinel sits inside a
60+
`parse_obj(...)` call and the field carries further keyword arguments.
61+
62+
oold's own regex stops at the first `)` after the address, which is the
63+
one belonging to `parse_obj(`, and so leaves a dangling `)` behind.
64+
"""
65+
bad = (
66+
"risk_assessment: RiskAssessmentProcess | None = Field("
67+
"default_factory=lambda :RiskAssessmentProcess.parse_obj("
68+
"<object object at 0x000001A2B3C4D5E6>), options={'a': 1})\n"
69+
)
70+
with pytest.raises(SyntaxError):
71+
ast.parse(bad)
72+
73+
fixed = remove_unserializable_default_sentinels(bad)
74+
75+
assert "<object object at" not in fixed
76+
assert "options={'a': 1}" in fixed # trailing kwargs are preserved
77+
ast.parse(fixed) # must not raise
78+
79+
80+
def test_legitimate_default_factory_lambda_is_not_mangled():
81+
"""A normal `default_factory=lambda: uuid4()` must survive untouched."""
82+
legit = "id: UUID = Field(default_factory=lambda: uuid4())\n"
83+
84+
assert remove_unserializable_default_sentinels(legit) == legit
85+
86+
87+
def test_ensure_valid_python_source_accepts_valid_source():
88+
ensure_valid_python_source("class Foo:\n pass\n", "entity.py") # no raise
89+
90+
91+
def test_ensure_valid_python_source_raises_with_a_useful_message():
92+
bad = "class Foo(:\n pass\n"
93+
94+
with pytest.raises(SyntaxError) as exc_info:
95+
ensure_valid_python_source(bad, "entity.py")
96+
97+
message = str(exc_info.value)
98+
assert "entity.py" in message
99+
assert "line 1" in message
100+
assert "class Foo(:" in message
101+
102+
103+
def test_validation_failure_leaves_an_existing_target_untouched(tmp_path):
104+
"""Mirrors the guarded write in _fetch_schema: validation runs before the
105+
file is ever opened for writing, so a bad generation never touches the
106+
previous, valid content sitting at the target path.
107+
"""
108+
target = tmp_path / "entity.py"
109+
target.write_text("previous_valid_content = 1\n", encoding="utf-8")
110+
111+
bad = "class Foo(:\n pass\n"
112+
113+
with pytest.raises(SyntaxError):
114+
ensure_valid_python_source(bad, str(target))
115+
target.write_text("corrupted", encoding="utf-8") # never reached
116+
117+
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)