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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -220,5 +220,4 @@ __marimo__/
# other stuff
artifacts/*
uv.lock
files/*
.DS_Store
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ Python dependencies are declared in `pyproject.toml`.

Spin up the application using Docker Compose. There are number of dependencies (Postgres and Redis) as well as Flask/Celery app components (`app`, `worker`, and optionially `flower`). Redis serves as the Celery broker (source of jobs) and Postgres is the Celery results backend.

In order to run Quiabo on the same system as [Mokelumne](https://github.com/BerkeleyLibrary/mokelumne) for development or integration testing, you need to symlink the `tmp` directory to Mokelumne's. Otherwise, you will need to create an empty `tmp` directory before starting Quiabo.

```bash
# assuming mokelumne is installed in this project's parent...
ln -s ../mokelumne/tmp tmp

# or, if you do not have mokelumne installed locally, make an empty tmp directory:
# mkdir tmp

Comment thread
jason-raitz marked this conversation as resolved.
# Build the Docker image for app, worker, and flower
docker compose build

Expand Down
8 changes: 2 additions & 6 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ services:
interval: 10s
retries: 5
start_period: 5s
ports:
- 5432:5432
restart: always
volumes:
- postgres-db-volume:/var/lib/postgresql/data
Expand All @@ -47,7 +45,7 @@ services:
- 8000:8000
volumes:
- ./:/app
- ./files:/srv/files:ro
- ./tmp:/srv:ro

worker:
build:
Expand All @@ -67,7 +65,7 @@ services:
command: celery -A quiabo.celery_app worker --loglevel INFO
volumes:
- ./:/app
- ./files:/srv/files
- ./tmp:/srv

flower:
build:
Expand Down Expand Up @@ -102,8 +100,6 @@ services:
retries: 50
start_period: 30s
image: redis:8
ports:
- 6379:6379
restart: always

volumes:
Expand Down
Empty file removed files/.keep
Empty file.
10 changes: 10 additions & 0 deletions quiabo/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
def run_tesseract_job(self, filelist: str, languages: list[str], output: str) -> dict:
"""Run Tesseract over the files listed in filelist and write output PDF."""

# is filelist a valid file with at least one line? If not, raise an error.
filelist_path = Path(filelist)
if not filelist_path.is_file():
raise FileNotFoundError(
f"File list {filelist} does not exist or is not a file."
)
with filelist_path.open("r", encoding="utf-8") as f:
if not any(line.strip() for line in f):
raise ValueError(f"File list {filelist} is empty.")

output = output.removesuffix(".pdf")

output_dir = Path(output).parent
Expand Down
44 changes: 34 additions & 10 deletions test/unit/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,27 @@ def test_shared_task_delay_delegates_to_apply_async(monkeypatch):
)


def test_run_tesseract_job_raises_error_for_empty_filelist(tmp_path):
"""Verify that the task rejects an empty file list."""
filelist = tmp_path / "empty.txt"
filelist.touch()

with pytest.raises(ValueError, match="is empty"):
run_tesseract_job.run(str(filelist), ["eng"], "output")


def test_run_tesseract_job_raises_error_for_missing_filelist(tmp_path):
"""Verify that the task rejects a missing file list."""
filelist = tmp_path / "missing.txt"

with pytest.raises(FileNotFoundError, match="does not exist"):
run_tesseract_job.run(str(filelist), ["eng"], "output")


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()
filelist.write_text("image1.tif\nimage2.tif\n", encoding="utf-8")
output = tmp_path / "output"
pdf_contents = b"generated PDF"

Expand Down Expand Up @@ -82,6 +99,8 @@ def test_run_tesseract_job_removes_pdf_suffix(monkeypatch, tmp_path):
run = Mock()
output = tmp_path / "output.pdf"
output_base = str(output.with_suffix(""))
filelist = tmp_path / "files.txt"
filelist.write_text("image1.tif\nimage2.tif\n", encoding="utf-8")

def write_output(**kwargs):
Path(f"{kwargs['output_filename_base']}.pdf").touch()
Expand All @@ -90,18 +109,18 @@ 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"], str(output))
result = run_tesseract_job.run(str(filelist), ["eng"], str(output))

update_state.assert_called_once_with(
state="STARTED",
meta={
"filelist": "files.txt",
"filelist": str(filelist),
"languages": ["eng"],
"output": output_base,
},
)
run.assert_called_once_with(
input_filename="files.txt",
input_filename=str(filelist),
output_filename_base=output_base,
extension="pdf",
lang="eng",
Expand All @@ -115,34 +134,39 @@ def write_output(**kwargs):
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"
filelist = tmp_path / "files.txt"
filelist.write_text("image1.tif\nimage2.tif\n", encoding="utf-8")

with pytest.raises(FileNotFoundError, match="does not exist"):
run_tesseract_job.run("files.txt", ["eng"], str(output))
run_tesseract_job.run(str(filelist), ["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"
filelist = tmp_path / "files.txt"
filelist.write_text("image1.tif\nimage2.tif\n", encoding="utf-8")
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))
run_tesseract_job.run(str(filelist), ["eng"], str(output))

assert exc_info.value.__cause__ is error


def test_run_tesseract_job_fails_when_language_unavailable(monkeypatch, tmp_path):
"""Verify that the task fails when Tesseract cannot load a language."""
output = tmp_path / "output"
filelist = tmp_path / "files.txt"
filelist.write_text("image1.tif\nimage2.tif\n", encoding="utf-8")
error = TesseractError(1, "Failed loading language 'swe'")
run = Mock(side_effect=error)

monkeypatch.setattr("quiabo.tasks.run_tesseract", run)

with pytest.raises(
RuntimeError, match="Failed loading language 'swe'"
):
run_tesseract_job.run("files.txt", ["swe"], str(output))
with pytest.raises(RuntimeError, match="Failed loading language 'swe'"):
run_tesseract_job.run(str(filelist), ["swe"], str(output))
Loading