From 2b1b9d8d994c1a88a311d5be1ba149f29a14c821 Mon Sep 17 00:00:00 2001 From: Steve Sullivan Date: Tue, 22 Sep 2026 14:05:06 -0700 Subject: [PATCH 1/4] Fix pip testing and linting install command --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 2de99d5..f81087b 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,7 @@ not installed by default, so you'll need to do that too. ```bash # Install the testing and linting dependencies - -docker compose exec app pip install --no-cache-dir -e .[test,lint] +docker compose exec app pip install --no-cache-dir -e '.[test,lint]' # Run all the tests docker compose exec app pytest From 63be87c8c32c55b255f0eeecb72c151b5fb356bd Mon Sep 17 00:00:00 2001 From: Steve Sullivan Date: Wed, 23 Sep 2026 15:07:42 -0700 Subject: [PATCH 2/4] Add validation for POST jobs endpoint --- quiabo/__init__.py | 3 +- quiabo/jobs.py | 46 +++++++++++++++++ test/unit/test_jobs.py | 110 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 quiabo/jobs.py create mode 100644 test/unit/test_jobs.py diff --git a/quiabo/__init__.py b/quiabo/__init__.py index fb46a17..2453f41 100644 --- a/quiabo/__init__.py +++ b/quiabo/__init__.py @@ -2,7 +2,7 @@ from flask import Flask -from quiabo import health, root +from quiabo import health, jobs, root from quiabo.celery import celery_init_app def create_app() -> Flask: @@ -16,6 +16,7 @@ def create_app() -> Flask: flask_app.register_blueprint(root.bp) flask_app.register_blueprint(health.bp) + flask_app.register_blueprint(jobs.bp) return flask_app diff --git a/quiabo/jobs.py b/quiabo/jobs.py new file mode 100644 index 0000000..71dcd94 --- /dev/null +++ b/quiabo/jobs.py @@ -0,0 +1,46 @@ +"""Routes for submitting and managing OCR jobs.""" + +import traceback + +from pathlib import Path +from flask import Blueprint, request + + +bp = Blueprint("jobs", __name__, url_prefix="/jobs") + + +@bp.post("") +def create_job(): + """Submit a new OCR job.""" + job = request.get_json() + + try: + for field in ("filelist", "languages", "output"): + if field not in job: + raise ValueError(f"{field} is required") + + if not isinstance(job["languages"], list): + raise ValueError("languages must be a list") + + if not job["languages"]: + raise ValueError("languages must not be empty") + + filelist = Path(job["filelist"]) + + if not filelist.exists(): + raise FileNotFoundError(f"{filelist} does not exist") + + except (ValueError, FileNotFoundError) as error: + return _error_response(error, 422) + + + return {}, 202 + + +def _error_response(error: Exception, status_code: int): + """Build error response.""" + return { + "status": "ERROR", + "result": str(error), + "traceback": traceback.format_exc(), + }, status_code diff --git a/test/unit/test_jobs.py b/test/unit/test_jobs.py new file mode 100644 index 0000000..6d70f39 --- /dev/null +++ b/test/unit/test_jobs.py @@ -0,0 +1,110 @@ +"""Tests for the jobs endpoints.""" + + +def test_create_job(client, tmp_path): + """POST /jobs returns and accepted response.""" + filelist = tmp_path / "filelist.txt" + filelist.touch() + + job = { + "filelist": str(filelist), + "languages": ["eng"], + "output": "/srv/test/output.pdf", + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 202 + +def test_create_job_requires_filelist(client): + """POST /jobs returns 422 when filelist is missing.""" + job = { + "languages": ["eng"], + "output": "/srv/test/output.pdf", + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 422 + assert response.json["status"] == "ERROR" + assert "filelist is required" in response.json["result"] + assert "traceback" in response.json + +def test_create_job_requires_existing_filelist(client): + """POST /jobs returns 422 when a filelist does not exist.""" + job = { + "filelist": "/srv/test/does-not-exist.txt", + "languages": ["eng"], + "output": "/srv/test/output.pdf", + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 422 + assert response.json["status"] == "ERROR" + assert "does not exist" in response.json["result"] + assert "traceback" in response.json + +def test_create_job_requires_languages(client, tmp_path): + """POST /jobs returns 422 when languages is missing.""" + filelist = tmp_path / "filelist.txt" + filelist.touch() + + job = { + "filelist": str(filelist), + "output": "/srv/test/output.pdf", + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 422 + assert response.json["status"] == "ERROR" + assert "languages is required" in response.json["result"] + assert "traceback" in response.json + +def test_create_job_requires_output(client, tmp_path): + """POST /jobs returns 422 when output is missing.""" + filelist = tmp_path / "filelist.txt" + filelist.touch() + + job = { + "filelist": str(filelist), + "languages": ["eng"], + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 422 + +def test_create_job_requires_languages_not_empty(client, tmp_path): + """POST /jobs returns 422 when languages is empty.""" + filelist = tmp_path / "filelist.txt" + filelist.touch() + + job = { + "filelist": str(filelist), + "languages": [], + "output": "/srv/test/output.pdf", + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 422 + +def test_create_job_requires_languages_list(client, tmp_path): + """POST /jobs returns 422 when languages is not a list.""" + filelist = tmp_path / "filelist.txt" + filelist.touch() + + job = { + "filelist": str(filelist), + "languages": "eng", + "output": "/srv/test/output.pdf", + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 422 + assert response.json["status"] == "ERROR" + assert "languages must be a list" in response.json["result"] + assert "traceback" in response.json From b6644bfed2111255f63d6cc6c5b7354d033338f7 Mon Sep 17 00:00:00 2001 From: Steve Sullivan Date: Fri, 25 Sep 2026 15:10:24 -0700 Subject: [PATCH 3/4] Queue OCR jobs from POST jobs endpoint --- quiabo/jobs.py | 21 ++++++++++++-- test/unit/test_jobs.py | 65 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/quiabo/jobs.py b/quiabo/jobs.py index 71dcd94..8af4310 100644 --- a/quiabo/jobs.py +++ b/quiabo/jobs.py @@ -5,6 +5,8 @@ from pathlib import Path from flask import Blueprint, request +from quiabo.tasks import run_tesseract_job + bp = Blueprint("jobs", __name__, url_prefix="/jobs") @@ -27,14 +29,27 @@ def create_job(): filelist = Path(job["filelist"]) - if not filelist.exists(): - raise FileNotFoundError(f"{filelist} does not exist") + if not filelist.is_file(): + raise FileNotFoundError(f"{filelist} does not exist or is not a file") except (ValueError, FileNotFoundError) as error: return _error_response(error, 422) - return {}, 202 + try: + celery_result = run_tesseract_job.delay( + job["filelist"], + job["languages"], + job["output"], + ) + except Exception as error: # pylint: disable=broad-exception-caught + return _error_response(error, 500) + + return { + "job_id": celery_result.id, + "status": "PENDING", + "job_status": f"/jobs/{celery_result.id}", + }, 202 def _error_response(error: Exception, status_code: int): diff --git a/test/unit/test_jobs.py b/test/unit/test_jobs.py index 6d70f39..8e3d5f5 100644 --- a/test/unit/test_jobs.py +++ b/test/unit/test_jobs.py @@ -1,8 +1,15 @@ """Tests for the jobs endpoints.""" +from unittest.mock import Mock -def test_create_job(client, tmp_path): - """POST /jobs returns and accepted response.""" +def test_create_job(client, tmp_path, monkeypatch): + """POST /jobs returns an accepted response.""" + fake_result = Mock() + fake_result.id = "12345" + delay = Mock(return_value=fake_result) + + monkeypatch.setattr("quiabo.jobs.run_tesseract_job.delay", delay) + filelist = tmp_path / "filelist.txt" filelist.touch() @@ -14,7 +21,18 @@ def test_create_job(client, tmp_path): response = client.post("/jobs", json=job) + delay.assert_called_once_with( + str(filelist), + ["eng"], + "/srv/test/output.pdf", + ) + assert response.status_code == 202 + assert response.json == { + "job_id": "12345", + "status": "PENDING", + "job_status": "/jobs/12345", + } def test_create_job_requires_filelist(client): """POST /jobs returns 422 when filelist is missing.""" @@ -75,6 +93,9 @@ def test_create_job_requires_output(client, tmp_path): response = client.post("/jobs", json=job) assert response.status_code == 422 + assert response.json["status"] == "ERROR" + assert "output is required" in response.json["result"] + assert "traceback" in response.json def test_create_job_requires_languages_not_empty(client, tmp_path): """POST /jobs returns 422 when languages is empty.""" @@ -90,6 +111,9 @@ def test_create_job_requires_languages_not_empty(client, tmp_path): response = client.post("/jobs", json=job) assert response.status_code == 422 + assert response.json["status"] == "ERROR" + assert "languages must not be empty" in response.json["result"] + assert "traceback" in response.json def test_create_job_requires_languages_list(client, tmp_path): """POST /jobs returns 422 when languages is not a list.""" @@ -108,3 +132,40 @@ def test_create_job_requires_languages_list(client, tmp_path): assert response.json["status"] == "ERROR" assert "languages must be a list" in response.json["result"] assert "traceback" in response.json + +def test_create_job_returns_500_when_queue_fails(client, tmp_path, monkeypatch): + """POST /jobs returns 500 when queue fails.""" + filelist = tmp_path / "filelist.txt" + filelist.touch() + + delay = Mock(side_effect=RuntimeError("queue unavailable")) + monkeypatch.setattr("quiabo.jobs.run_tesseract_job.delay", delay) + + response = client.post("/jobs", json={ + "filelist": str(filelist), + "languages": ["eng"], + "output": "/srv/test/output.pdf", + }) + + assert response.status_code == 500 + assert response.json["status"] == "ERROR" + assert "queue unavailable" in response.json["result"] + assert "traceback" in response.json + +def test_create_job_requires_filelist_to_be_file(client, tmp_path): + """POST /jobs returns 422 when filelist is not a file.""" + filelist = tmp_path / "filelist" + filelist.mkdir() + + job = { + "filelist": str(filelist), + "languages": ["eng"], + "output": "/srv/test/output.pdf", + } + + response = client.post("/jobs", json=job) + + assert response.status_code == 422 + assert response.json["status"] == "ERROR" + assert "does not exist or is not a file" in response.json["result"] + assert "traceback" in response.json From 536f6975d2b86869f97cd74df190edcd6441b020 Mon Sep 17 00:00:00 2001 From: Steve Sullivan Date: Mon, 28 Sep 2026 13:05:03 -0700 Subject: [PATCH 4/4] Fix Celery backend and OCR test fixture --- pyproject.toml | 2 +- test/fixtures/files/files.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dabba13..78af6b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "flask", "flower", "gunicorn", - "psycopg2", + "psycopg[c]", "redis", "sqlalchemy" ] diff --git a/test/fixtures/files/files.txt b/test/fixtures/files/files.txt index 0e2a2d3..e3ddc23 100644 --- a/test/fixtures/files/files.txt +++ b/test/fixtures/files/files.txt @@ -1 +1 @@ -/app/test/fixture/files/testocr.png +/app/test/fixtures/files/testocr.png