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 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/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..8af4310 --- /dev/null +++ b/quiabo/jobs.py @@ -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 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 diff --git a/test/unit/test_jobs.py b/test/unit/test_jobs.py new file mode 100644 index 0000000..8e3d5f5 --- /dev/null +++ b/test/unit/test_jobs.py @@ -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