From d4d3c33e702ae7792fc6e42cb10578180902f847 Mon Sep 17 00:00:00 2001 From: Jason Raitz Date: Tue, 29 Sep 2026 17:11:40 -0400 Subject: [PATCH 1/4] pytesseract & updates - adds pytesseract dependency - updates some volumes in docker-compose - note: directly imports run_tesseract which is not explicitly exported by the pytesseract package --- .gitignore | 2 ++ docker-compose.yml | 11 +++++------ pyproject.toml | 1 + quiabo/tasks.py | 23 ++++++++++++++++++----- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 2abc6e4..857e48d 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,5 @@ __marimo__/ # other stuff artifacts/* uv.lock +files/* +.DS_Store diff --git a/docker-compose.yml b/docker-compose.yml index 63e9f59..67c94a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,8 +46,8 @@ services: ports: - 8000:8000 volumes: - - ./quiabo:/app/quiabo:rw - - ./test:/app/test:rw + - ./:/app + - ./files:/srv/files:ro worker: build: @@ -66,12 +66,13 @@ services: restart: always command: celery -A quiabo.celery_app worker --loglevel INFO volumes: - - ./quiabo:/app/quiabo:rw - - ./files:/app/files:rw + - ./:/app + - ./files:/srv/files flower: build: context: . + target: app profiles: - flower depends_on: @@ -90,8 +91,6 @@ services: start_period: 30s ports: - 127.0.0.1:5555:5555 - volumes: - - ./quiabo:/app/quiabo:rw redis: healthcheck: diff --git a/pyproject.toml b/pyproject.toml index 505ad48..64eb5e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "flower", "gunicorn", "psycopg[c]", + "pytesseract", "redis", "sqlalchemy" ] diff --git a/quiabo/tasks.py b/quiabo/tasks.py index 348586e..f472b8d 100644 --- a/quiabo/tasks.py +++ b/quiabo/tasks.py @@ -1,8 +1,9 @@ """Celery tasks for running OCR jobs.""" -import subprocess +import hashlib from pathlib import Path +from pytesseract.pytesseract import run_tesseract from celery import shared_task @@ -22,7 +23,19 @@ def run_tesseract_job(self, filelist: str, languages: list[str], output: str) -> meta={"filelist": filelist, "languages": languages, "output": output}, ) - command = ["tesseract", "-l", "+".join(languages), filelist, output, "pdf"] - subprocess.run(command, check=True, capture_output=True, text=True) - - return {"output": f"{output}.pdf"} + kwargs = { + "input_filename": filelist, + "output_filename_base": output, + "extension": "pdf", + "lang": "+".join(languages), + } + + try: + run_tesseract(**kwargs) + output_path = Path(f"{output}.pdf") + with output_path.open("rb") as f: + sha256 = hashlib.file_digest(f, "sha256").hexdigest() + except Exception as e: + raise RuntimeError(f"Error running Tesseract: {e}") from e + + return {"output_path": str(output_path), "sha256": sha256} From 13338069a26df37a66af766ee659b6483945c80d Mon Sep 17 00:00:00 2001 From: Jason Raitz Date: Tue, 29 Sep 2026 17:25:05 -0400 Subject: [PATCH 2/4] update tasks tests --- test/unit/test_tasks.py | 84 ++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/test/unit/test_tasks.py b/test/unit/test_tasks.py index 3673846..688b0d4 100644 --- a/test/unit/test_tasks.py +++ b/test/unit/test_tasks.py @@ -1,7 +1,11 @@ """Unit tests for the shared task defined in ``tasks.py``.""" +import hashlib +from pathlib import Path from unittest.mock import Mock +import pytest + from quiabo.tasks import run_tesseract_job @@ -28,42 +32,60 @@ def test_shared_task_delay_delegates_to_apply_async(monkeypatch): ) -def test_run_tesseract_job_updates_state_and_runs_tesseract(monkeypatch): - """Verify that the task updates state and invokes Tesseract.""" +def test_run_tesseract_job_updates_state_and_returns_digest(monkeypatch, tmp_path): + """Verify that the task runs Tesseract and returns the PDF digest.""" + filelist = tmp_path / "files.txt" + filelist.touch() + output = tmp_path / "output" + pdf_contents = b"generated PDF" + update_state = Mock() run = Mock() + + def write_output(**kwargs): + Path(f"{kwargs['output_filename_base']}.pdf").write_bytes(pdf_contents) + + run.side_effect = write_output monkeypatch.setattr(run_tesseract_job, "update_state", update_state) - monkeypatch.setattr("quiabo.tasks.subprocess.run", run) + monkeypatch.setattr("quiabo.tasks.run_tesseract", run) result = run_tesseract_job.run( - "files.txt", + str(filelist), ["eng", "spa"], - "output", + str(output), ) update_state.assert_called_once_with( state="STARTED", meta={ - "filelist": "files.txt", + "filelist": str(filelist), "languages": ["eng", "spa"], - "output": "output", + "output": str(output), }, ) run.assert_called_once_with( - ["tesseract", "-l", "eng+spa", "files.txt", "output", "pdf"], - check=True, - capture_output=True, - text=True, + input_filename=str(filelist), + output_filename_base=str(output), + extension="pdf", + lang="eng+spa", ) - assert result == {"output": "output.pdf"} + assert result == { + "output_path": f"{output}.pdf", + "sha256": hashlib.sha256(pdf_contents).hexdigest(), + } def test_run_tesseract_job_removes_pdf_suffix(monkeypatch): """Verify that an existing PDF suffix is removed before invocation.""" update_state = Mock() run = Mock() + + def write_output(**kwargs): + Path(f"{kwargs['output_filename_base']}.pdf").touch() + + run.side_effect = write_output monkeypatch.setattr(run_tesseract_job, "update_state", update_state) - monkeypatch.setattr("quiabo.tasks.subprocess.run", run) + monkeypatch.setattr("quiabo.tasks.run_tesseract", run) result = run_tesseract_job.run("files.txt", ["eng"], "output.pdf") @@ -76,9 +98,35 @@ def test_run_tesseract_job_removes_pdf_suffix(monkeypatch): }, ) run.assert_called_once_with( - ["tesseract", "-l", "eng", "files.txt", "output", "pdf"], - check=True, - capture_output=True, - text=True, + input_filename="files.txt", + output_filename_base="output", + extension="pdf", + lang="eng", ) - assert result == {"output": "output.pdf"} + assert result == { + "output_path": "output.pdf", + "sha256": hashlib.sha256(b"").hexdigest(), + } + + +def test_run_tesseract_job_requires_existing_output_directory(tmp_path): + """Verify that the task rejects a missing output directory.""" + output = tmp_path / "missing" / "output" + + with pytest.raises(FileNotFoundError, match="does not exist"): + run_tesseract_job.run("files.txt", ["eng"], str(output)) + + +def test_run_tesseract_job_wraps_tesseract_errors(monkeypatch, tmp_path): + """Verify that Tesseract errors are wrapped with task context.""" + output = tmp_path / "output" + error = RuntimeError("Tesseract failed") + run = Mock(side_effect=error) + monkeypatch.setattr("quiabo.tasks.run_tesseract", run) + + with pytest.raises( + RuntimeError, match="Error running Tesseract: Tesseract failed" + ) as exc_info: + run_tesseract_job.run("files.txt", ["eng"], str(output)) + + assert exc_info.value.__cause__ is error From c7f61b8f8471c8cdf300a1067528ede83cd4cb7e Mon Sep 17 00:00:00 2001 From: Jason Raitz Date: Wed, 30 Sep 2026 14:05:06 -0400 Subject: [PATCH 3/4] PR comments --- docker-compose.yml | 2 ++ quiabo/tasks.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 67c94a7..1261195 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,8 @@ services: start_period: 30s ports: - 127.0.0.1:5555:5555 + volumes: + - ./:/app redis: healthcheck: diff --git a/quiabo/tasks.py b/quiabo/tasks.py index f472b8d..1720762 100644 --- a/quiabo/tasks.py +++ b/quiabo/tasks.py @@ -3,6 +3,8 @@ import hashlib from pathlib import Path +# directly imports run_tesseract which is not explicitly exported by the pytesseract package +# The two exported functions we could use force a tmp file to be created and then deleted. from pytesseract.pytesseract import run_tesseract from celery import shared_task From ab2034ca60398c6b92b8f253ad8ec35a547e065b Mon Sep 17 00:00:00 2001 From: Jason Raitz Date: Fri, 2 Oct 2026 14:16:45 -0400 Subject: [PATCH 4/4] use tmp_path for unit test --- test/unit/test_tasks.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/unit/test_tasks.py b/test/unit/test_tasks.py index 688b0d4..bd9b595 100644 --- a/test/unit/test_tasks.py +++ b/test/unit/test_tasks.py @@ -75,10 +75,12 @@ def write_output(**kwargs): } -def test_run_tesseract_job_removes_pdf_suffix(monkeypatch): +def test_run_tesseract_job_removes_pdf_suffix(monkeypatch, tmp_path): """Verify that an existing PDF suffix is removed before invocation.""" update_state = Mock() run = Mock() + output = tmp_path / "output.pdf" + output_base = str(output.with_suffix("")) def write_output(**kwargs): Path(f"{kwargs['output_filename_base']}.pdf").touch() @@ -87,24 +89,24 @@ def write_output(**kwargs): monkeypatch.setattr(run_tesseract_job, "update_state", update_state) monkeypatch.setattr("quiabo.tasks.run_tesseract", run) - result = run_tesseract_job.run("files.txt", ["eng"], "output.pdf") + result = run_tesseract_job.run("files.txt", ["eng"], str(output)) update_state.assert_called_once_with( state="STARTED", meta={ "filelist": "files.txt", "languages": ["eng"], - "output": "output", + "output": output_base, }, ) run.assert_called_once_with( input_filename="files.txt", - output_filename_base="output", + output_filename_base=output_base, extension="pdf", lang="eng", ) assert result == { - "output_path": "output.pdf", + "output_path": str(output), "sha256": hashlib.sha256(b"").hexdigest(), }