Skip to content

Commit ad9784b

Browse files
Copilotoscarlevin
andauthored
Raise on failed individual asset generation to prevent incorrect cache updates (#1130)
* Initial plan * Add error handling for failed image generation and tests to verify behavior - In generate.py, each individual_* function now logs an error and raises an Exception when core does not produce the expected output file. This ensures the failure propagates up through generate_assets() so the asset type is not added to the successful_assets list and the cache is not incorrectly updated. - Add tests/test_generate.py with 8 tests covering all four individual_* functions, verifying both the failure case (raises) and success case (no raise). Co-authored-by: oscarlevin <6504596+oscarlevin@users.noreply.github.com> Agent-Logs-Url: https://github.com/PreTeXtBook/pretext-cli/sessions/7b12f474-b534-45aa-813c-9a2f75f4d8c3 * Initial plan * Rebase on main, run black formatting, add CHANGELOG entry - Rebased on origin/main (includes new tests and changes from #1124-#1128) - Ran `poetry run black .` for formatting (reformatted test_generate.py) - Added Fixed entry in CHANGELOG.md under [Unreleased] Co-authored-by: oscarlevin <6504596+oscarlevin@users.noreply.github.com> Agent-Logs-Url: https://github.com/PreTeXtBook/pretext-cli/sessions/72d5b309-fec0-4dad-8af0-1cbe035c2679 * drop failed assets from cache --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: oscarlevin <6504596+oscarlevin@users.noreply.github.com> Co-authored-by: Oscar Levin <oscar.levin@unco.edu>
1 parent 48f71f8 commit ad9784b

4 files changed

Lines changed: 256 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ Instructions: Add a subsection under `[Unreleased]` for additions, fixes, change
99

1010
## [Unreleased]
1111

12+
### Fixed
13+
14+
- Failed individual asset generation (when core does not produce the expected output file) now raises an error and is no longer silently treated as a success. This prevents incorrectly caching incomplete asset generation results.
15+
1216
### Changed
1317

1418
- The default Asymptote generation method is now `local` (using the local `asy` binary) instead of `server`. This improves reliability since the server method has been subject to breakage whenever the Asymptote web interface is updated. Users who need the server method can still opt in via `asy-method="server"` in `project.ptx`.

pretext/project/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1292,10 +1292,20 @@ def generate_assets(
12921292
log.debug(e, exc_info=True)
12931293
# After all assets are generated, update the asset cache (but we shouldn't do this if we didn't generate any assets successfully)
12941294
log.debug(f"Updated these assets successfully: {successful_assets}")
1295-
if len(successful_assets) > 0 and not xmlid:
1295+
unsuccessful_assets = [
1296+
asset for asset in assets_to_generate if asset not in successful_assets
1297+
]
1298+
log.debug(f"These assets were unsuccessful: {unsuccessful_assets}")
1299+
if not xmlid:
12961300
# If the build limited by xmlid, then we don't know that all assets of that type were build correctly, so we only do this if not generating a subset.
1301+
# First create a list of unsuccessful assets to log:
12971302
for asset_type in successful_assets:
12981303
saved_asset_table[asset_type] = source_asset_table[asset_type]
1304+
for asset_type in unsuccessful_assets:
1305+
log.debug(
1306+
f"Asset type {asset_type} was requested to be generated, but was not generated successfully. This asset type will be regenerated on the next build, even if the source files have not changed, since we can't be sure that the existing generated assets are up to date."
1307+
)
1308+
saved_asset_table.pop(asset_type, None)
12991309
# Save the asset table to disk:
13001310
self.save_asset_table(saved_asset_table)
13011311
log.info("Finished generating assets.\n")

pretext/project/generate.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def individual_prefigure(
7474
f"Created prefigure diagram {annotations_output_file}; saving a copy to cache as {annotations_cache_file}"
7575
)
7676
shutil.copy2(annotations_output_file, annotations_cache_file)
77+
else:
78+
msg = f"Failed to generate prefigure diagram {Path(pfdiagram).name} in {format} format."
79+
log.error(msg)
80+
raise Exception(msg)
7781
log.debug("Finished individual_prefigure function")
7882

7983

@@ -109,6 +113,10 @@ def individual_asymptote(
109113
f"Created asymptote diagram {output_file}; saving a copy to cache as {cache_file}"
110114
)
111115
shutil.copy2(output_file, cache_file)
116+
else:
117+
msg = f"Failed to generate asymptote diagram {Path(asydiagram).name} in {outformat} format."
118+
log.error(msg)
119+
raise Exception(msg)
112120
log.debug("Finished individual_asymptote function")
113121

114122

@@ -146,6 +154,10 @@ def individual_sage(
146154
f"Created sageplot diagram {output_file}; saving a copy to cache as {cache_file}"
147155
)
148156
shutil.copy2(output_file, cache_file)
157+
else:
158+
msg = f"Failed to generate sageplot diagram {Path(sageplot).name} in {outformat} format."
159+
log.error(msg)
160+
raise Exception(msg)
149161
log.debug("Finished individual_sage function")
150162

151163

@@ -191,6 +203,10 @@ def individual_latex_image(
191203
f"Created latex-image {output_files[ext]}; saving a copy to cache as {cache_files[ext]}"
192204
)
193205
shutil.copy2(output_files[ext], cache_files[ext])
206+
else:
207+
msg = f"Failed to generate latex-image {Path(latex_image).name} in {ext} format."
208+
log.error(msg)
209+
raise Exception(msg)
194210
log.debug("Finished individual_latex function")
195211

196212

tests/test_generate.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Tests to ensure that failed image generation is not reported as success.
3+
4+
When a core conversion function fails to produce an output file, the
5+
individual_* functions in pretext/project/generate.py should raise an
6+
exception so that the asset type is not added to the generated asset cache.
7+
"""
8+
9+
from pathlib import Path
10+
from unittest.mock import patch
11+
12+
import pytest
13+
14+
from pretext.project import generate
15+
16+
17+
def _make_asset_file(tmp_path: Path, name: str = "test_asset.tex") -> Path:
18+
"""Create a minimal asset file in tmp_path for use in tests."""
19+
asset_file = tmp_path / name
20+
asset_file.write_text("some asset content")
21+
return asset_file
22+
23+
24+
def test_individual_latex_image_raises_on_missing_output(tmp_path: Path) -> None:
25+
"""individual_latex_image should raise when core does not produce the output file."""
26+
asset_file = _make_asset_file(tmp_path, "image.tex")
27+
cache_dir = tmp_path / "cache" / "latex-image"
28+
cache_dir.mkdir(parents=True)
29+
dest_dir = tmp_path / "dest"
30+
dest_dir.mkdir()
31+
32+
# Patch core so it does NOT create the output file.
33+
with patch("pretext.project.generate.core.individual_latex_image_conversion"):
34+
with pytest.raises(Exception, match="Failed to generate latex-image"):
35+
generate.individual_latex_image(
36+
latex_image=str(asset_file),
37+
outformat="svg",
38+
dest_dir=dest_dir,
39+
method="xelatex",
40+
cache_dir=tmp_path / "cache",
41+
skip_cache=True,
42+
)
43+
44+
45+
def test_individual_latex_image_succeeds_when_output_exists(tmp_path: Path) -> None:
46+
"""individual_latex_image should not raise when core produces the output file."""
47+
asset_file = _make_asset_file(tmp_path, "image.tex")
48+
cache_dir = tmp_path / "cache" / "latex-image"
49+
cache_dir.mkdir(parents=True)
50+
dest_dir = tmp_path / "dest"
51+
dest_dir.mkdir()
52+
53+
def fake_conversion(
54+
latex_image: str, outformat: str, dest_dir: Path, method: str
55+
) -> None:
56+
# Simulate a successful conversion by creating the expected output file.
57+
(dest_dir / Path(latex_image).with_suffix(".svg").name).touch()
58+
59+
with patch(
60+
"pretext.project.generate.core.individual_latex_image_conversion",
61+
side_effect=fake_conversion,
62+
):
63+
# Should not raise.
64+
generate.individual_latex_image(
65+
latex_image=str(asset_file),
66+
outformat="svg",
67+
dest_dir=dest_dir,
68+
method="xelatex",
69+
cache_dir=tmp_path / "cache",
70+
skip_cache=True,
71+
)
72+
73+
74+
def test_individual_asymptote_raises_on_missing_output(tmp_path: Path) -> None:
75+
"""individual_asymptote should raise when core does not produce the output file."""
76+
asset_file = _make_asset_file(tmp_path, "diagram.asy")
77+
cache_dir = tmp_path / "cache" / "asymptote"
78+
cache_dir.mkdir(parents=True)
79+
dest_dir = tmp_path / "dest"
80+
dest_dir.mkdir()
81+
82+
with patch("pretext.project.generate.core.individual_asymptote_conversion"):
83+
with pytest.raises(Exception, match="Failed to generate asymptote diagram"):
84+
generate.individual_asymptote(
85+
asydiagram=str(asset_file),
86+
outformat="svg",
87+
method="server",
88+
asy_cli=[],
89+
asyversion="",
90+
alberta="",
91+
dest_dir=dest_dir,
92+
cache_dir=tmp_path / "cache",
93+
skip_cache=True,
94+
)
95+
96+
97+
def test_individual_asymptote_succeeds_when_output_exists(tmp_path: Path) -> None:
98+
"""individual_asymptote should not raise when core produces the output file."""
99+
asset_file = _make_asset_file(tmp_path, "diagram.asy")
100+
cache_dir = tmp_path / "cache" / "asymptote"
101+
cache_dir.mkdir(parents=True)
102+
dest_dir = tmp_path / "dest"
103+
dest_dir.mkdir()
104+
105+
def fake_conversion(
106+
asydiagram: str,
107+
outformat: str,
108+
method: str,
109+
asy_cli: list,
110+
asyversion: str,
111+
alberta: str,
112+
dest_dir: Path,
113+
) -> None:
114+
(dest_dir / Path(asydiagram).with_suffix(".svg").name).touch()
115+
116+
with patch(
117+
"pretext.project.generate.core.individual_asymptote_conversion",
118+
side_effect=fake_conversion,
119+
):
120+
generate.individual_asymptote(
121+
asydiagram=str(asset_file),
122+
outformat="svg",
123+
method="server",
124+
asy_cli=[],
125+
asyversion="",
126+
alberta="",
127+
dest_dir=dest_dir,
128+
cache_dir=tmp_path / "cache",
129+
skip_cache=True,
130+
)
131+
132+
133+
def test_individual_sage_raises_on_missing_output(tmp_path: Path) -> None:
134+
"""individual_sage should raise when core does not produce the output file."""
135+
asset_file = _make_asset_file(tmp_path, "plot.sage")
136+
cache_dir = tmp_path / "cache" / "sageplot"
137+
cache_dir.mkdir(parents=True)
138+
dest_dir = tmp_path / "dest"
139+
dest_dir.mkdir()
140+
141+
with patch("pretext.project.generate.core.individual_sage_conversion"):
142+
with pytest.raises(Exception, match="Failed to generate sageplot diagram"):
143+
generate.individual_sage(
144+
sageplot=str(asset_file),
145+
outformat="svg",
146+
dest_dir=dest_dir,
147+
sage_executable_cmd=["sage"],
148+
cache_dir=tmp_path / "cache",
149+
skip_cache=True,
150+
)
151+
152+
153+
def test_individual_sage_succeeds_when_output_exists(tmp_path: Path) -> None:
154+
"""individual_sage should not raise when core produces the output file."""
155+
asset_file = _make_asset_file(tmp_path, "plot.sage")
156+
cache_dir = tmp_path / "cache" / "sageplot"
157+
cache_dir.mkdir(parents=True)
158+
dest_dir = tmp_path / "dest"
159+
dest_dir.mkdir()
160+
161+
def fake_conversion(
162+
sageplot: str,
163+
outformat: str,
164+
dest_dir: Path,
165+
sage_executable_cmd: list,
166+
) -> None:
167+
(dest_dir / Path(sageplot).with_suffix(".svg").name).touch()
168+
169+
with patch(
170+
"pretext.project.generate.core.individual_sage_conversion",
171+
side_effect=fake_conversion,
172+
):
173+
generate.individual_sage(
174+
sageplot=str(asset_file),
175+
outformat="svg",
176+
dest_dir=dest_dir,
177+
sage_executable_cmd=["sage"],
178+
cache_dir=tmp_path / "cache",
179+
skip_cache=True,
180+
)
181+
182+
183+
def test_individual_prefigure_raises_on_missing_output(tmp_path: Path) -> None:
184+
"""individual_prefigure should raise when core does not produce the output file."""
185+
asset_file = _make_asset_file(tmp_path, "figure.xml")
186+
cache_dir = tmp_path / "cache" / "prefigure"
187+
cache_dir.mkdir(parents=True)
188+
tmp_dir = tmp_path / "tmp_work"
189+
tmp_dir.mkdir()
190+
191+
with patch("pretext.project.generate.core.individual_prefigure_conversion"):
192+
with pytest.raises(Exception, match="Failed to generate prefigure diagram"):
193+
generate.individual_prefigure(
194+
pfdiagram=str(asset_file),
195+
outformat="svg",
196+
tmp_dir=str(tmp_dir),
197+
cache_dir=tmp_path / "cache",
198+
skip_cache=True,
199+
)
200+
201+
202+
def test_individual_prefigure_succeeds_when_output_exists(tmp_path: Path) -> None:
203+
"""individual_prefigure should not raise when core produces the output file."""
204+
asset_file = _make_asset_file(tmp_path, "figure.xml")
205+
cache_dir = tmp_path / "cache" / "prefigure"
206+
cache_dir.mkdir(parents=True)
207+
tmp_dir = tmp_path / "tmp_work"
208+
tmp_dir.mkdir()
209+
210+
def fake_conversion(pfdiagram: str, outformat: str) -> None:
211+
output_dir = tmp_dir / "output"
212+
output_dir.mkdir(parents=True, exist_ok=True)
213+
(output_dir / Path(pfdiagram).with_suffix(".svg").name).touch()
214+
215+
with patch(
216+
"pretext.project.generate.core.individual_prefigure_conversion",
217+
side_effect=fake_conversion,
218+
):
219+
generate.individual_prefigure(
220+
pfdiagram=str(asset_file),
221+
outformat="svg",
222+
tmp_dir=str(tmp_dir),
223+
cache_dir=tmp_path / "cache",
224+
skip_cache=True,
225+
)

0 commit comments

Comments
 (0)