Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dependencies = [
"flask",
"flower",
"gunicorn",
"psycopg2",
"psycopg[c]",
"redis",
"sqlalchemy"
]
Expand Down
3 changes: 2 additions & 1 deletion quiabo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
61 changes: 61 additions & 0 deletions quiabo/jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Routes for submitting and managing OCR jobs."""

import traceback

from pathlib import Path
from flask import Blueprint, request

from quiabo.tasks import run_tesseract_job


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.is_file():
raise FileNotFoundError(f"{filelist} does not exist or is not a file")

except (ValueError, FileNotFoundError) as error:
return _error_response(error, 422)


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):
"""Build error response."""
return {
"status": "ERROR",
"result": str(error),
"traceback": traceback.format_exc(),
}, status_code
2 changes: 1 addition & 1 deletion test/fixtures/files/files.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
/app/test/fixture/files/testocr.png
/app/test/fixtures/files/testocr.png
171 changes: 171 additions & 0 deletions test/unit/test_jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Tests for the jobs endpoints."""
from unittest.mock import Mock


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()

job = {
"filelist": str(filelist),
"languages": ["eng"],
"output": "/srv/test/output.pdf",
}

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."""
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
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."""
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
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."""
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

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
Loading