Skip to content

Commit 662e20c

Browse files
m-messerclaude
andcommitted
fix: address PR review feedback on Mathpix failure paths
Adds timeouts to all Mathpix HTTP calls, polls the conversion status endpoint instead of treating every non-200 as "not ready", surfaces Mathpix's in-band error bodies instead of raising a bare KeyError, warns instead of silently skipping a failed figure download, and returns the markdown as a string rather than writing it into out_dir (which would otherwise collide with the user's chosen output file once the wizard command wires this up). Also documents that PDFs are sent to a third-party OCR service and that Mathpix offers a training opt-out. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a33c96f commit 662e20c

2 files changed

Lines changed: 134 additions & 46 deletions

File tree

in2lambda/wizard/mathpix.py

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
Needs ``MATHPIX_APP_ID`` and ``MATHPIX_API_KEY`` in the environment (a ``.env``
44
file is honoured by the wizard). Figures referenced by the returned markdown are
55
downloaded next to it so the ``Markdown`` filter can pick them up.
6+
7+
The PDF is uploaded to Mathpix, a third-party OCR service, for processing.
8+
Instructors converting student work should be told their PDFs leave the
9+
local machine. Mathpix also offers an opt-out from using submitted data to
10+
improve its models; see https://mathpix.com/privacy for how to enable it.
611
"""
712

813
import os
914
import re
1015
import time
16+
import warnings
1117
from pathlib import Path
1218

1319
import requests
@@ -35,65 +41,93 @@ def pdf_to_markdown(
3541
out_dir: str,
3642
poll_interval: float = 5.0,
3743
max_polls: int = 60,
38-
) -> Path:
39-
"""Convert ``pdf_path`` to markdown, writing it and its figures under ``out_dir``.
44+
timeout: float = 30.0,
45+
) -> str:
46+
"""Convert ``pdf_path`` to markdown, downloading its figures under ``out_dir``.
4047
4148
Args:
4249
pdf_path: Path to the source PDF.
43-
out_dir: Directory to write ``<stem>.md`` and a ``media/`` folder into.
50+
out_dir: Directory to write a ``media/`` folder of figures into.
4451
poll_interval: Seconds to wait between Mathpix "is it ready yet" polls.
4552
max_polls: How many times to poll before giving up.
53+
timeout: Seconds to wait for each individual HTTP request.
4654
4755
Returns:
48-
The path to the written markdown file. Figures are saved in
49-
``<out_dir>/media/`` and referenced from the markdown as
50-
``./media/<name>``.
56+
The converted markdown, with figures saved in ``<out_dir>/media/`` and
57+
referenced from the markdown as ``./media/<name>``. The caller is
58+
responsible for writing the markdown out wherever it belongs.
5159
5260
Raises:
53-
RuntimeError: if credentials are missing or Mathpix does not finish in time.
61+
RuntimeError: if credentials are missing, Mathpix rejects the PDF or
62+
fails to convert it, or the conversion does not finish in time.
5463
"""
5564
headers = _headers()
5665
out = Path(out_dir)
5766
(out / "media").mkdir(parents=True, exist_ok=True)
5867

5968
with open(pdf_path, "rb") as pdf:
6069
response = requests.post(
61-
MATHPIX_PDF_ENDPOINT, headers=headers, files={"file": pdf}
70+
MATHPIX_PDF_ENDPOINT,
71+
headers=headers,
72+
files={"file": pdf},
73+
timeout=timeout,
6274
)
6375
response.raise_for_status()
64-
pdf_id = response.json()["pdf_id"]
76+
data = response.json()
77+
if "error" in data:
78+
raise RuntimeError(f"Mathpix rejected the PDF: {data['error']}")
79+
pdf_id = data["pdf_id"]
6580

66-
markdown = _poll_for_markdown(pdf_id, headers, poll_interval, max_polls)
67-
markdown = _localise_figures(markdown, out)
68-
69-
md_path = out / f"{Path(pdf_path).stem}.md"
70-
md_path.write_text(markdown, encoding="utf-8")
71-
return md_path
81+
markdown = _poll_for_markdown(pdf_id, headers, poll_interval, max_polls, timeout)
82+
return _localise_figures(markdown, out, timeout)
7283

7384

7485
def _poll_for_markdown(
75-
pdf_id: str, headers: dict, poll_interval: float, max_polls: int
86+
pdf_id: str,
87+
headers: dict,
88+
poll_interval: float,
89+
max_polls: int,
90+
timeout: float,
7691
) -> str:
77-
"""Poll Mathpix until the ``.md`` render of ``pdf_id`` is ready."""
78-
url = f"{MATHPIX_PDF_ENDPOINT}/{pdf_id}.md"
92+
"""Poll Mathpix until ``pdf_id`` finishes converting, then return its markdown."""
93+
status_url = f"{MATHPIX_PDF_ENDPOINT}/{pdf_id}"
7994
for _ in range(max_polls):
80-
response = requests.get(url, headers=headers)
81-
if response.status_code == 200:
82-
return response.text
95+
response = requests.get(status_url, headers=headers, timeout=timeout)
96+
response.raise_for_status()
97+
data = response.json()
98+
status = data.get("status")
99+
if status == "completed":
100+
break
101+
if status == "error":
102+
raise RuntimeError(
103+
f"Mathpix failed to convert {pdf_id}: {data.get('error', 'unknown error')}"
104+
)
83105
time.sleep(poll_interval)
84-
raise RuntimeError(f"Mathpix did not finish converting {pdf_id} in time.")
106+
else:
107+
raise RuntimeError(f"Mathpix did not finish converting {pdf_id} in time.")
108+
109+
md_response = requests.get(
110+
f"{MATHPIX_PDF_ENDPOINT}/{pdf_id}.md", headers=headers, timeout=timeout
111+
)
112+
md_response.raise_for_status()
113+
return md_response.text
85114

86115

87-
def _localise_figures(markdown: str, out_dir: Path) -> str:
116+
def _localise_figures(markdown: str, out_dir: Path, timeout: float) -> str:
88117
"""Download remote figures into ``out_dir/media`` and repoint the markdown at them."""
89118
markdown = markdown.replace("![]", "![pictureTag]")
90119

91120
for idx, url in enumerate(dict.fromkeys(_REMOTE_IMAGE.findall(markdown))):
92121
basename = os.path.basename(url).split("?")[0] or f"figure_{idx}.png"
93122
local_name = f"{idx}_{basename}"
94123

95-
image = requests.get(url)
124+
image = requests.get(url, timeout=timeout)
96125
if image.status_code != 200:
126+
warnings.warn(
127+
f"Mathpix figure download failed for {url} "
128+
f"(status {image.status_code}); markdown will reference a "
129+
f"missing file: ./media/{local_name}"
130+
)
97131
continue
98132

99133
(out_dir / "media" / local_name).write_bytes(image.content)

tests/test_mathpix.py

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,62 +19,116 @@ def _pdf(tmp_path):
1919
return pdf
2020

2121

22-
def test_pdf_to_markdown_writes_md_and_localises_figures(tmp_path):
22+
def _post(pdf_id="abc123", error=None):
23+
post = MagicMock(status_code=200)
24+
post.json.return_value = {"error": error} if error else {"pdf_id": pdf_id}
25+
return post
26+
27+
28+
def _status(status, error=None):
29+
body = {"status": status}
30+
if error:
31+
body["error"] = error
32+
resp = MagicMock(status_code=200)
33+
resp.json.return_value = body
34+
return resp
35+
36+
37+
def test_pdf_to_markdown_returns_markdown_and_localises_figures(tmp_path):
2338
pdf = _pdf(tmp_path)
2439
out_dir = tmp_path / "out"
2540

26-
post = MagicMock(status_code=200)
27-
post.json.return_value = {"pdf_id": "abc123"}
41+
completed = _status("completed")
2842
md = MagicMock(
2943
status_code=200,
3044
text="# Heading\n\n![](https://cdn.mathpix.com/x/fig.png?width=8) done\n",
3145
)
3246
image = MagicMock(status_code=200, content=b"PNGBYTES")
3347

3448
with patch("in2lambda.wizard.mathpix.requests") as req:
35-
req.post.return_value = post
36-
req.get.side_effect = [md, image]
37-
md_path = pdf_to_markdown(str(pdf), str(out_dir), poll_interval=0.0)
49+
req.post.return_value = _post()
50+
req.get.side_effect = [completed, md, image]
51+
markdown = pdf_to_markdown(str(pdf), str(out_dir), poll_interval=0.0)
3852

39-
assert md_path == out_dir / "paper.md"
40-
text = md_path.read_text()
41-
assert "![pictureTag](./media/0_fig.png)" in text
53+
assert "![pictureTag](./media/0_fig.png)" in markdown
54+
assert not (out_dir / "paper.md").exists()
4255
assert (out_dir / "media" / "0_fig.png").read_bytes() == b"PNGBYTES"
4356

4457

4558
def test_pdf_to_markdown_polls_until_ready(tmp_path):
4659
pdf = _pdf(tmp_path)
4760

48-
post = MagicMock(status_code=200)
49-
post.json.return_value = {"pdf_id": "abc123"}
50-
not_ready = MagicMock(status_code=202)
51-
ready = MagicMock(status_code=200, text="# Only text, no figures\n")
61+
processing = _status("processing")
62+
completed = _status("completed")
63+
md = MagicMock(status_code=200, text="# Only text, no figures\n")
5264

5365
with patch("in2lambda.wizard.mathpix.requests") as req:
54-
req.post.return_value = post
55-
req.get.side_effect = [not_ready, not_ready, ready]
56-
md_path = pdf_to_markdown(
66+
req.post.return_value = _post()
67+
req.get.side_effect = [processing, processing, completed, md]
68+
markdown = pdf_to_markdown(
5769
str(pdf), str(tmp_path / "out"), poll_interval=0.0, max_polls=5
5870
)
5971

60-
assert md_path.read_text().startswith("# Only text")
72+
assert markdown.startswith("# Only text")
6173

6274

6375
def test_pdf_to_markdown_times_out(tmp_path):
6476
pdf = _pdf(tmp_path)
6577

66-
post = MagicMock(status_code=200)
67-
post.json.return_value = {"pdf_id": "abc123"}
68-
6978
with patch("in2lambda.wizard.mathpix.requests") as req:
70-
req.post.return_value = post
71-
req.get.return_value = MagicMock(status_code=202)
79+
req.post.return_value = _post()
80+
req.get.return_value = _status("processing")
7281
with pytest.raises(RuntimeError, match="did not finish"):
7382
pdf_to_markdown(
7483
str(pdf), str(tmp_path / "out"), poll_interval=0.0, max_polls=3
7584
)
7685

7786

87+
def test_pdf_to_markdown_raises_on_rejected_upload(tmp_path):
88+
pdf = _pdf(tmp_path)
89+
90+
with patch("in2lambda.wizard.mathpix.requests") as req:
91+
req.post.return_value = _post(error="Invalid file type")
92+
with pytest.raises(RuntimeError, match="Mathpix rejected the PDF"):
93+
pdf_to_markdown(str(pdf), str(tmp_path / "out"))
94+
95+
96+
def test_pdf_to_markdown_raises_immediately_on_conversion_error(tmp_path):
97+
pdf = _pdf(tmp_path)
98+
99+
with patch("in2lambda.wizard.mathpix.requests") as req:
100+
req.post.return_value = _post()
101+
req.get.return_value = _status("error", error="conversion failed")
102+
with pytest.raises(RuntimeError, match="conversion failed"):
103+
pdf_to_markdown(
104+
str(pdf), str(tmp_path / "out"), poll_interval=0.0, max_polls=60
105+
)
106+
107+
# Only the single status poll should have happened, not all 60.
108+
assert req.get.call_count == 1
109+
110+
111+
def test_pdf_to_markdown_warns_on_failed_figure_download(tmp_path):
112+
pdf = _pdf(tmp_path)
113+
out_dir = tmp_path / "out"
114+
115+
completed = _status("completed")
116+
md = MagicMock(
117+
status_code=200,
118+
text="![](https://cdn.mathpix.com/x/fig.png) done\n",
119+
)
120+
image = MagicMock(status_code=404, content=b"")
121+
122+
with patch("in2lambda.wizard.mathpix.requests") as req:
123+
req.post.return_value = _post()
124+
req.get.side_effect = [completed, md, image]
125+
with pytest.warns(UserWarning, match="figure download failed"):
126+
markdown = pdf_to_markdown(str(pdf), str(out_dir), poll_interval=0.0)
127+
128+
assert "https://cdn.mathpix.com/x/fig.png" in markdown
129+
assert not (out_dir / "media" / "0_fig.png").exists()
130+
131+
78132
def test_missing_credentials_raise(tmp_path, monkeypatch):
79133
monkeypatch.delenv("MATHPIX_APP_ID", raising=False)
80134
monkeypatch.delenv("MATHPIX_API_KEY", raising=False)

0 commit comments

Comments
 (0)