From 1613303890279d0c6d8a38dcdce07a9fc74916c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Thu, 10 Sep 2026 20:10:52 -0700 Subject: [PATCH 1/9] AP-856: scaffold a basic flask+celery app * creates a simple app with a 2 routes: / and /health * creates a celery app that can be extended further * sets up Docker and Compose configuration to spin up the necessary services: db, app, worker, and redis * adds github actions workflows --- .github/workflows/build.yml | 81 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 13 ++++++ .gitignore | 4 ++ Dockerfile | 47 ++++++++++++++++++++ artifacts/.keep | 0 bin/dbinit | 20 +++++++++ docker-compose.ci.yml | 22 ++++++++++ docker-compose.yml | 67 +++++++++++++++++++++++++++++ env.example | 3 ++ pyproject.toml | 67 +++++++++++++++++++++++++++++ quiabo/__init__.py | 34 +++++++++++++++ quiabo/celery.py | 14 ++++++ test/__init__.py | 0 test/conftest.py | 23 ++++++++++ test/unit/__init__.py | 0 test/unit/test_celery.py | 7 +++ test/unit/test_routes.py | 8 ++++ 17 files changed, 410 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml create mode 100644 Dockerfile create mode 100644 artifacts/.keep create mode 100755 bin/dbinit create mode 100644 docker-compose.ci.yml create mode 100644 docker-compose.yml create mode 100644 env.example create mode 100644 pyproject.toml create mode 100644 quiabo/__init__.py create mode 100644 quiabo/celery.py create mode 100644 test/__init__.py create mode 100644 test/conftest.py create mode 100644 test/unit/__init__.py create mode 100644 test/unit/test_celery.py create mode 100644 test/unit/test_routes.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5de4a18 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,81 @@ +name: Build / Test / Push + +on: + push: + branches: + - "**" + workflow_call: + workflow_dispatch: + +env: + BUILD_SUFFIX: -build-${{ github.run_id }}_${{ github.run_attempt }} + +jobs: + docker-build: + uses: BerkeleyLibrary/.github/.github/workflows/docker-build.yml@3.1.0 + with: + image: ghcr.io/${{ github.repository }} + secrets: inherit + + test: + runs-on: ubuntu-24.04 + needs: docker-build + env: + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + DOCKER_APP_IMAGE: ${{ needs.docker-build.outputs.image }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Docker Compose + uses: docker/setup-compose-action@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Basic setup + run: | + ARTIFACTS_DIR="${RUNNER_TEMP}/artifacts" + mkdir -p "$ARTIFACTS_DIR" + echo "ARTIFACTS_DIR=${ARTIFACTS_DIR}" >> $GITHUB_ENV + echo "TEST_START=$(date +%s)" >> $GITHUB_ENV + - name: Setup the stack + run: | + docker compose run app bin/dbinit + docker compose up --wait + - name: Install testing and linting dependencies + run: | + docker compose exec app pip install --no-cache-dir -e .[test,lint] + - name: Run pytest + run: | + docker compose exec app pytest + + - name: Copy out artifacts + if: ${{ always() }} + run: | + docker compose cp app:/app/artifacts "${ARTIFACTS_DIR}" || true + docker compose logs > "${ARTIFACTS_DIR}/docker-compose-services.log" + docker compose config > "${ARTIFACTS_DIR}/docker-compose.merged.yml" + docker events --json --since $TEST_START --until `date +%s` | tee "${ARTIFACTS_DIR}/docker-events.json" + + - name: Upload the test report + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: quiabo Build Report (${{ github.run_id }}_${{ github.run_attempt }}) + path: ${{ env.ARTIFACTS_DIR }} + if-no-files-found: error + + push: + needs: + - docker-build + - test + uses: BerkeleyLibrary/.github/.github/workflows/docker-push.yml@3.1.0 + with: + image: ghcr.io/${{ github.repository }} + build-image-arm64: ${{ needs.docker-build.outputs.image-arm64 }} + build-image-x64: ${{ needs.docker-build.outputs.image-x64 }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8a60fa7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,13 @@ +name: Release + +on: + push: + tags: + - '**' + workflow_dispatch: + +jobs: + release: + uses: BerkeleyLibrary/.github/.github/workflows/docker-release.yml@main + with: + image: ghcr.io/${{ github.repository }} diff --git a/.gitignore b/.gitignore index 83972fa..174c4d0 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# other stuff +artifacts/* +uv.lock \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e0a920 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM python:3.14-slim AS reqs + +ENV APP_USER=quiabo +ENV APP_UID=49999 +ENV VIRTUAL_ENV=/venv + +RUN apt-get update -y && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + libxml2-dev \ + python3-dev \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/ + +RUN groupadd --system --gid $APP_UID $APP_USER \ + && useradd --home-dir /app --system --uid $APP_UID --gid $APP_USER $APP_USER + +RUN mkdir -p /app && mkdir -p /venv + +RUN chown -R $APP_USER:$APP_USER /app /venv + +USER $APP_USER + +RUN python -m venv /venv +ENV PATH=/venv/bin:$PATH + +RUN python -m pip install -U setuptools + +WORKDIR /app + +COPY pyproject.toml . + +FROM reqs AS app + +WORKDIR /app +USER $APP_USER + +COPY quiabo quiabo +COPY bin bin +COPY README.md README.md +COPY test test +RUN pip install --no-cache-dir -e . + +EXPOSE 8000 + +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0", "quiabo:app"] diff --git a/artifacts/.keep b/artifacts/.keep new file mode 100644 index 0000000..e69de29 diff --git a/bin/dbinit b/bin/dbinit new file mode 100755 index 0000000..327ddb4 --- /dev/null +++ b/bin/dbinit @@ -0,0 +1,20 @@ +#!/bin/sh -e + +# Initialise the configured database environment for use with quiabo +# +# Copyright © 2026 The Regents of the University of California. MIT license. + +# Use the credentials from the app's environment. +export PGHOST=${POSTGRES_HOST:-db} +export PGPORT=${POSTGRES_PORT:-5432} +export PGUSER=${POSTGRES_USER} +export PGPASSWORD=${POSTGRES_PASSWORD} +export PGDATABASE=${POSTGRES_DB} + +# Determine if the database needs to be created or not. +if [ "$(psql -d template1 -t -A -c "SELECT COUNT(*) FROM pg_database WHERE datname='${POSTGRES_DB}';")" = '0' ]; then + echo Creating database ${POSTGRES_DB}... + createdb +else + echo Database ${POSTGRES_DB} already exists. Bye bye! +fi diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..47df5c4 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,22 @@ +services: + db: + volumes: !reset + + redis: + volumes: !reset + + app: + build: !reset + depends_on: !reset + ports: !reset + image: ${DOCKER_APP_IMAGE} + env_file: !override env.example + volumes: !reset + + worker: + build: !reset + image: ${DOCKER_APP_IMAGE} + depends_on: !reset + ports: !reset + env_file: !override env.example + volumes: !reset diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0db1a81 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +services: + + db: + environment: &dbconfig + POSTGRES_USER: ${POSTGRES_USER:-quiabo} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-quiabo} + POSTGRES_HOST: ${POSTGRES_HOST:-db} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-quiabo} + DATABASE_URL: ${DATABASE_URL:-db+postgresql://${POSTGRES_USER:-quiabo}:${POSTGRES_PASSWORD:-quiabo}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-quiabo}} + image: postgres:16 + healthcheck: + test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER:-root}", "-d", "${POSTGRES_DB:-quiabo}"] + interval: 10s + retries: 5 + start_period: 5s + ports: + - 5432:5432 + restart: always + volumes: + - postgres-db-volume:/var/lib/postgresql/data + + app: + build: + context: . + depends_on: + - db + - redis + environment: + <<: *dbconfig + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + init: true + restart: always + ports: + - 8000:8000 + volumes: + - ./quiabo:/app/quiabo:rw + + worker: + build: + context: . + depends_on: + - db + - redis + environment: + <<: *dbconfig + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + init: true + restart: always + command: celery -A quiabo.celery_app worker --loglevel INFO + volumes: + - ./quiabo:/app/quiabo:rw + + redis: + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 30s + retries: 50 + start_period: 30s + image: redis:8 + ports: + - 6379:6379 + restart: always + +volumes: + postgres-db-volume: diff --git a/env.example b/env.example new file mode 100644 index 0000000..5e7f0ad --- /dev/null +++ b/env.example @@ -0,0 +1,3 @@ +POSTGRES_USER=root +POSTGRES_PASSWORD=root +POSTGRES_DB=quiabo diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fdaa049 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["setuptools >= 77.0.3"] +build-backend = "setuptools.build_meta" + +[project] +name = "quiabo" +description = "Backend web service for running OCR processes" +version = "0.0.1" +dependencies = [ + "celery", + "flask", + "gunicorn", + "psycopg2", + "redis", + "sqlalchemy" +] +requires-python = ">= 3.13, < 3.15" +authors = [ + {name = "maría a. matienzo"}, + {name = "Jason Raitz"}, + {name = "Steve Sullivan"}, +] +readme = "README.md" +license = "MIT" + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", +] +lint = [ + "mypy ~= 1.17.1", + "pydoclint ~= 0.6.10", + "pylint ~= 3.3", +] + +[project.urls] +Repository = "https://github.com/BerkeleyLibrary/quiabo" +Issues = "https://github.com/BerkeleyLibrary/quiabo" + +[tool.mypy] +python_version = "3.13" +warn_unused_configs = true +warn_redundant_casts = true +warn_return_any = true + +[tool.pydoclint] +allow-init-docstring = true +skip-checking-raises = true +style = "sphinx" + +[tool.pytest] +minversion = "9.0" +addopts = [ + "-v", + "-s", + "--junit-xml=artifacts/pytest.xml", + "--cov-report=term", + "--cov-report=html:artifacts/coverage", + "--cov=quiabo", +] +markers = [ + "unit: fast isolated unit tests with no external dependencies", +] + +[tool.setuptools] +py-modules = ["quiabo"] diff --git a/quiabo/__init__.py b/quiabo/__init__.py new file mode 100644 index 0000000..838e349 --- /dev/null +++ b/quiabo/__init__.py @@ -0,0 +1,34 @@ +import os + +from flask import Flask + +from quiabo.celery import celery_init_app + +DATABASE_URL = os.getenv("DATABASE_URL") +REDIS_URL = os.getenv("REDIS_URL") + +app = Flask(__name__) + +app.config.from_mapping( + CELERY=dict( + broker_url=REDIS_URL, + result_backend=DATABASE_URL, + task_ignore_result=False, + ), +) + +celery_app = celery_init_app(app) + +@app.route('/') +def index(): + return "Goodbye Doggy!" + + +@app.route('/health') +def health(): + return { + "default": { + "message": "Application is running", + "success": True + } + } diff --git a/quiabo/celery.py b/quiabo/celery.py new file mode 100644 index 0000000..9ba1972 --- /dev/null +++ b/quiabo/celery.py @@ -0,0 +1,14 @@ +from celery import Celery, Task +from flask import Flask + +def celery_init_app(app: Flask) -> Celery: + class FlaskTask(Task): + def __call__(self, *args: object, **kwargs: object) -> object: + with app.app_context(): + return self.run(*args, **kwargs) + + celery_app = Celery(app.name, task_cls=FlaskTask) + celery_app.config_from_object(app.config["CELERY"]) + celery_app.set_default() + app.extensions["celery"] = celery_app + return celery_app diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..ab5e03a --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,23 @@ +import pytest +from quiabo import app as flask_app + +@pytest.fixture() +def app(): + app = flask_app + app.config.update({ + "TESTING": True, + }) + + yield app + + # clean up / reset resources here + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def runner(app): + return app.test_cli_runner() diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/test_celery.py b/test/unit/test_celery.py new file mode 100644 index 0000000..fda6e2b --- /dev/null +++ b/test/unit/test_celery.py @@ -0,0 +1,7 @@ +from quiabo import celery +from celery import Celery as CeleryApp + +def test_celery_init_app(app): + with app.app_context(): + celery_app = celery.celery_init_app(app) + assert isinstance(celery_app, CeleryApp) diff --git a/test/unit/test_routes.py b/test/unit/test_routes.py new file mode 100644 index 0000000..98bd696 --- /dev/null +++ b/test/unit/test_routes.py @@ -0,0 +1,8 @@ +def test_root_route(client): + response = client.get("/") + assert b"Goodbye Doggy!" in response.data + + +def test_health_route(client): + response = client.get("/health") + assert response.json["default"]["success"] == True From f5d93538880f24f156129138fefe3efdf1afb405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Fri, 11 Sep 2026 08:19:06 -0700 Subject: [PATCH 2/9] add celery flower and change default queue --- docker-compose.yml | 24 ++++++++++++++++++++++++ pyproject.toml | 1 + quiabo/__init__.py | 1 + 3 files changed, 26 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 0db1a81..370231e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,30 @@ services: volumes: - ./quiabo:/app/quiabo:rw + flower: + build: + context: . + profiles: + - flower + depends_on: + redis: + condition: service_healthy + environment: + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + init: true + restart: always + command: celery -A quiabo.celery_app flower + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:5555/"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + ports: + - 127.0.0.1:5555:5555 + volumes: + - ./quiabo:/app/quiabo:rw + redis: healthcheck: test: ["CMD", "redis-cli", "ping"] diff --git a/pyproject.toml b/pyproject.toml index fdaa049..dabba13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ version = "0.0.1" dependencies = [ "celery", "flask", + "flower", "gunicorn", "psycopg2", "redis", diff --git a/quiabo/__init__.py b/quiabo/__init__.py index 838e349..c452aa4 100644 --- a/quiabo/__init__.py +++ b/quiabo/__init__.py @@ -13,6 +13,7 @@ CELERY=dict( broker_url=REDIS_URL, result_backend=DATABASE_URL, + task_default_queue="quiabo", task_ignore_result=False, ), ) From ee8cc9bcbe8af8f486f827474b18943063b57f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Fri, 11 Sep 2026 09:05:19 -0700 Subject: [PATCH 3/9] refactor controllers into blueprints --- quiabo/__init__.py | 38 +++++++++----------- quiabo/health.py | 13 +++++++ quiabo/root.py | 8 +++++ test/unit/{test_routes.py => test_health.py} | 5 --- test/unit/test_root.py | 3 ++ 5 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 quiabo/health.py create mode 100644 quiabo/root.py rename test/unit/{test_routes.py => test_health.py} (53%) create mode 100644 test/unit/test_root.py diff --git a/quiabo/__init__.py b/quiabo/__init__.py index c452aa4..c147a45 100644 --- a/quiabo/__init__.py +++ b/quiabo/__init__.py @@ -2,34 +2,28 @@ from flask import Flask +from quiabo import health, root from quiabo.celery import celery_init_app DATABASE_URL = os.getenv("DATABASE_URL") REDIS_URL = os.getenv("REDIS_URL") -app = Flask(__name__) +def create_app() -> Flask: + app = Flask(__name__) + app.config.from_mapping( + CELERY=dict( + broker_url=REDIS_URL, + result_backend=DATABASE_URL, + task_default_queue="quiabo", + task_ignore_result=False, + ), + ) -app.config.from_mapping( - CELERY=dict( - broker_url=REDIS_URL, - result_backend=DATABASE_URL, - task_default_queue="quiabo", - task_ignore_result=False, - ), -) + app.register_blueprint(root.bp) + app.register_blueprint(health.bp) -celery_app = celery_init_app(app) - -@app.route('/') -def index(): - return "Goodbye Doggy!" + return app +app = create_app() +celery_app = celery_init_app(app) -@app.route('/health') -def health(): - return { - "default": { - "message": "Application is running", - "success": True - } - } diff --git a/quiabo/health.py b/quiabo/health.py new file mode 100644 index 0000000..a9c5d9c --- /dev/null +++ b/quiabo/health.py @@ -0,0 +1,13 @@ +from flask import Blueprint + +bp = Blueprint("health", __name__, url_prefix="/health") + +@bp.route("") +def default() -> dict[str, dict[str, str|bool]]: + """Default healthcheck endpoint.""" + return { + "default": { + "message": "Application is running", + "success": True + } + } diff --git a/quiabo/root.py b/quiabo/root.py new file mode 100644 index 0000000..9571eac --- /dev/null +++ b/quiabo/root.py @@ -0,0 +1,8 @@ +from flask import Blueprint + +bp = Blueprint("root", __name__, url_prefix="") + +@bp.route("/") +def index() -> str: + """Default root endpoint.""" + return "

Goodbye Doggy!

" diff --git a/test/unit/test_routes.py b/test/unit/test_health.py similarity index 53% rename from test/unit/test_routes.py rename to test/unit/test_health.py index 98bd696..ec938b7 100644 --- a/test/unit/test_routes.py +++ b/test/unit/test_health.py @@ -1,8 +1,3 @@ -def test_root_route(client): - response = client.get("/") - assert b"Goodbye Doggy!" in response.data - - def test_health_route(client): response = client.get("/health") assert response.json["default"]["success"] == True diff --git a/test/unit/test_root.py b/test/unit/test_root.py new file mode 100644 index 0000000..747b034 --- /dev/null +++ b/test/unit/test_root.py @@ -0,0 +1,3 @@ +def test_root_route(client): + response = client.get("/") + assert b"Goodbye Doggy!" in response.data From 0b12f56480e16017ef1f2279151f638b325206ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Fri, 11 Sep 2026 12:08:57 -0700 Subject: [PATCH 4/9] get flask app config from prefixed env variables --- docker-compose.yml | 40 ++++++++++++++++++++++++++-------------- quiabo/__init__.py | 12 +----------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 370231e..a6f29cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,26 @@ -services: +x-dbconfig: + environment: &dbconfig + POSTGRES_USER: ${POSTGRES_USER:-quiabo} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-quiabo} + POSTGRES_HOST: ${POSTGRES_HOST:-db} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-quiabo} + +x-quiabo-common: + environment: &quiabo-common-environment + QUIABO_CELERY__broker_url: ${QUIABO__CELERY__broker_url:-redis://redis:6379/} + QUIABO_CELERY__result_backend: ${QUIABO__CELERY__result_backend:-db+postgresql://${POSTGRES_USER:-quiabo}:${POSTGRES_PASSWORD:-quiabo}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-quiabo}} + QUIABO_CELERY__task_default_queue: quiabo + QUIABO_CELERY__task_ignore_result: false + +services: db: - environment: &dbconfig - POSTGRES_USER: ${POSTGRES_USER:-quiabo} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-quiabo} - POSTGRES_HOST: ${POSTGRES_HOST:-db} - POSTGRES_PORT: ${POSTGRES_PORT:-5432} - POSTGRES_DB: ${POSTGRES_DB:-quiabo} - DATABASE_URL: ${DATABASE_URL:-db+postgresql://${POSTGRES_USER:-quiabo}:${POSTGRES_PASSWORD:-quiabo}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-quiabo}} + environment: + <<: *dbconfig image: postgres:16 healthcheck: - test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER:-root}", "-d", "${POSTGRES_DB:-quiabo}"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] interval: 10s retries: 5 start_period: 5s @@ -27,8 +37,9 @@ services: - db - redis environment: - <<: *dbconfig - REDIS_URL: ${REDIS_URL:-redis://redis:6379} + <<: + - *quiabo-common-environment + - *dbconfig init: true restart: always ports: @@ -43,8 +54,9 @@ services: - db - redis environment: - <<: *dbconfig - REDIS_URL: ${REDIS_URL:-redis://redis:6379} + <<: + - *quiabo-common-environment + - *dbconfig init: true restart: always command: celery -A quiabo.celery_app worker --loglevel INFO @@ -60,7 +72,7 @@ services: redis: condition: service_healthy environment: - REDIS_URL: ${REDIS_URL:-redis://redis:6379} + <<: *quiabo-common-environment init: true restart: always command: celery -A quiabo.celery_app flower diff --git a/quiabo/__init__.py b/quiabo/__init__.py index c147a45..f1512cb 100644 --- a/quiabo/__init__.py +++ b/quiabo/__init__.py @@ -5,19 +5,9 @@ from quiabo import health, root from quiabo.celery import celery_init_app -DATABASE_URL = os.getenv("DATABASE_URL") -REDIS_URL = os.getenv("REDIS_URL") - def create_app() -> Flask: app = Flask(__name__) - app.config.from_mapping( - CELERY=dict( - broker_url=REDIS_URL, - result_backend=DATABASE_URL, - task_default_queue="quiabo", - task_ignore_result=False, - ), - ) + app.config.from_prefixed_env(prefix="QUIABO") app.register_blueprint(root.bp) app.register_blueprint(health.bp) From 383826b0fbcefd4ac06bc9efba8a8903cc36310d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Fri, 11 Sep 2026 12:51:32 -0700 Subject: [PATCH 5/9] add readme and tighten up compose file --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 18 ++++++++------ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index dbccc8a..2de99d5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,59 @@ -# quiabo -Backend web service for running OCR processes +# ![quiabo logo](quiabo/static/quiabo.png) quiabo + +`quiabo` is a backend web service for running OCR jobs implemented as a Flask and Celery application. + +## Dependencies + +Python dependencies are declared in `pyproject.toml`. + +## Development + +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. + +```bash +# Build the Docker image for app, worker, and flower +docker compose build + +# Create the postgres database; only needed the first time +docker compose run --rm app bin/dbinit + +# Start the Flask app, which will be running on http://localhost:8000/ +docker compose up --detach + +# Optionally start Flower, which is a dashboard for the Celery queue and +# will be running on http://localhost:5555/ +docker compose up --profile flower --detach +``` + +## Testing + +Once the stack is started, execute the tests by running `pytest` in one +of the running containers. Note that testing and linting dependencies are +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] + +# Run all the tests +docker compose exec app pytest + +# Run tests with a specific marker +# Example: only run the unit tests +docker compose exec app pytest -m unit +``` + +Test results/reports are written to `./artifacts/pytest`. + +## Configuration + +`quiabo`'s configuration is handled by environment variables using Flask's +[`from_prefixed_env()`](https://flask.palletsprojects.com/en/stable/config/#configuring-from-environment-variables) method, using `QUIABO` as the +prefix. Celery configuration is set using the same method. At a minimum, +you will need to set the following: + +| Environment variable | Purpose | Example | +| -------------------- | ------- | ------- | +| `QUIABO_CELERY__broker_url` | Connection URL for the Celery broker (e.g. Redis) | `redis://redis:6379` | +| `QUIABO_CELERY__result_backend` | SQLAlchemy connection URL for the Celery result backend (e.g. Postgres) | `db+postgresql://postgres:postgres@db:5432/quiabo` | diff --git a/docker-compose.yml b/docker-compose.yml index a6f29cc..1d40c3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,10 +8,10 @@ x-dbconfig: x-quiabo-common: environment: &quiabo-common-environment - QUIABO_CELERY__broker_url: ${QUIABO__CELERY__broker_url:-redis://redis:6379/} + QUIABO_CELERY__broker_url: ${QUIABO__CELERY__broker_url:-redis://redis:6379} QUIABO_CELERY__result_backend: ${QUIABO__CELERY__result_backend:-db+postgresql://${POSTGRES_USER:-quiabo}:${POSTGRES_PASSWORD:-quiabo}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-quiabo}} - QUIABO_CELERY__task_default_queue: quiabo - QUIABO_CELERY__task_ignore_result: false + QUIABO_CELERY__task_default_queue: ${QUIABO_CELERY__task_default_queue:-quiabo} + QUIABO_CELERY__task_ignore_result: ${QUIABO_CELERY__task_ignore_result:-false} services: @@ -34,8 +34,10 @@ services: build: context: . depends_on: - - db - - redis + db: + condition: service_healthy + redis: + condition: service_healthy environment: <<: - *quiabo-common-environment @@ -51,8 +53,10 @@ services: build: context: . depends_on: - - db - - redis + db: + condition: service_healthy + redis: + condition: service_healthy environment: <<: - *quiabo-common-environment From b6fc592f0be36a706c4b6d4cb21f5a6926f2cd2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Fri, 11 Sep 2026 12:51:50 -0700 Subject: [PATCH 6/9] add a nice root page with a silly image --- quiabo/root.py | 4 ++-- quiabo/static/quiabo.png | Bin 0 -> 8503 bytes quiabo/templates/root/index.html | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 quiabo/static/quiabo.png create mode 100644 quiabo/templates/root/index.html diff --git a/quiabo/root.py b/quiabo/root.py index 9571eac..9f7ec4b 100644 --- a/quiabo/root.py +++ b/quiabo/root.py @@ -1,8 +1,8 @@ -from flask import Blueprint +from flask import Blueprint, render_template bp = Blueprint("root", __name__, url_prefix="") @bp.route("/") def index() -> str: """Default root endpoint.""" - return "

Goodbye Doggy!

" + return render_template('root/index.html') diff --git a/quiabo/static/quiabo.png b/quiabo/static/quiabo.png new file mode 100644 index 0000000000000000000000000000000000000000..bb420bdc375f6967af9e9078d06ebdf913df4d4c GIT binary patch literal 8503 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 H#pC}0WOrE{ literal 0 HcmV?d00001 diff --git a/quiabo/templates/root/index.html b/quiabo/templates/root/index.html new file mode 100644 index 0000000..08ac543 --- /dev/null +++ b/quiabo/templates/root/index.html @@ -0,0 +1,8 @@ + + + quiabo + + quiabo logo +

Goodbye Doggy!

+ + From c78a8937f4e2e1ee2477981a894102fdb4ce59d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Fri, 11 Sep 2026 14:05:42 -0700 Subject: [PATCH 7/9] update uid to align with what's in lap/workflow --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7e0a920..d0bd838 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.14-slim AS reqs ENV APP_USER=quiabo -ENV APP_UID=49999 +ENV APP_UID=40098 ENV VIRTUAL_ENV=/venv RUN apt-get update -y && apt-get upgrade -y \ From e2ef6dd402c50aff6743c12d704e3a553834857e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Mon, 14 Sep 2026 10:58:18 -0700 Subject: [PATCH 8/9] Add docstrings and minor refactor based on pylint --- quiabo/__init__.py | 18 +++++++++++------- quiabo/celery.py | 22 ++++++++++++++++++++++ quiabo/health.py | 14 +++++++++++++- quiabo/root.py | 4 ++++ test/conftest.py | 22 ++++++++++++++++++++-- test/unit/test_celery.py | 5 ++++- test/unit/test_health.py | 7 +++++-- test/unit/test_root.py | 3 +++ 8 files changed, 82 insertions(+), 13 deletions(-) diff --git a/quiabo/__init__.py b/quiabo/__init__.py index f1512cb..fb46a17 100644 --- a/quiabo/__init__.py +++ b/quiabo/__init__.py @@ -1,4 +1,4 @@ -import os +"""Flask application intialization functions.""" from flask import Flask @@ -6,14 +6,18 @@ from quiabo.celery import celery_init_app def create_app() -> Flask: - app = Flask(__name__) - app.config.from_prefixed_env(prefix="QUIABO") + """ + Creates the Flask application. - app.register_blueprint(root.bp) - app.register_blueprint(health.bp) + :rtype: flask.Flask + """ + flask_app = Flask(__name__) + flask_app.config.from_prefixed_env(prefix="QUIABO") - return app + flask_app.register_blueprint(root.bp) + flask_app.register_blueprint(health.bp) + + return flask_app app = create_app() celery_app = celery_init_app(app) - diff --git a/quiabo/celery.py b/quiabo/celery.py index 9ba1972..ced5443 100644 --- a/quiabo/celery.py +++ b/quiabo/celery.py @@ -1,9 +1,31 @@ +"""Code to initialize the Celery application based on an existing Flask app.""" + from celery import Celery, Task from flask import Flask def celery_init_app(app: Flask) -> Celery: + """ + Given a properly configured Flask application, return a configured + Celery app. + """ class FlaskTask(Task): + """ + Class used to provide access to Celery decoratorss. + + :see https://flask.palletsprojects.com/en/stable/patterns/celery/ + """ def __call__(self, *args: object, **kwargs: object) -> object: + """ + Create a callable instance since Celery otherwise does not + have direct access to the Flask application context. + + :param args: positional arguments to get passed in the call + :type args: object + :param kwargs: keyword arguments to get passed in the call: + :type kwargs: object + :return: The output of the task to be run. + :rtype: Object + """ with app.app_context(): return self.run(*args, **kwargs) diff --git a/quiabo/health.py b/quiabo/health.py index a9c5d9c..5b206f5 100644 --- a/quiabo/health.py +++ b/quiabo/health.py @@ -1,10 +1,22 @@ +"""Route/controller for a healthcheck endpoint. Requires significant expansion.""" + from flask import Blueprint bp = Blueprint("health", __name__, url_prefix="/health") @bp.route("") def default() -> dict[str, dict[str, str|bool]]: - """Default healthcheck endpoint.""" + """ + Default healthcheck endpoint. Because of Flask magic, this gets returned + to the client as a JSON object. + + Healthchecks are expected to contain a key for the healthcheck, the + message, and a bool for whether the check is successful. + + :return: The output of the healthcheck. Currently only confirms that + the application is running and can serve the route. + :rtype: dict[str, dict[str, str|bool]] + """ return { "default": { "message": "Application is running", diff --git a/quiabo/root.py b/quiabo/root.py index 9f7ec4b..d963d96 100644 --- a/quiabo/root.py +++ b/quiabo/root.py @@ -1,3 +1,7 @@ +""" +Route/controller for the application root. +""" + from flask import Blueprint, render_template bp = Blueprint("root", __name__, url_prefix="") diff --git a/test/conftest.py b/test/conftest.py index ab5e03a..2614a47 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,8 +1,18 @@ +# pylint: disable=W0621 + +"""pytest setup and fixtures for quiabo.""" + import pytest +from flask import Flask from quiabo import app as flask_app @pytest.fixture() def app(): + """Create a configured instance of the Flask application for testing. + + :return: The Flask application. + :rtype: flask.Flask + """ app = flask_app app.config.update({ "TESTING": True, @@ -14,10 +24,18 @@ def app(): @pytest.fixture() -def client(app): +def client(app: Flask): + """ + Given a configured application, return a test HTTP client for checking + routes/controllers. + """ return app.test_client() @pytest.fixture() -def runner(app): +def runner(app: Flask): + """ + Given a configured application, return a test runner for running CLI + commands. + """ return app.test_cli_runner() diff --git a/test/unit/test_celery.py b/test/unit/test_celery.py index fda6e2b..4dddcf7 100644 --- a/test/unit/test_celery.py +++ b/test/unit/test_celery.py @@ -1,7 +1,10 @@ -from quiabo import celery +"""Test Celery app initialization.""" + from celery import Celery as CeleryApp +from quiabo import celery def test_celery_init_app(app): + """Ensure a test Celery application is instantiated.""" with app.app_context(): celery_app = celery.celery_init_app(app) assert isinstance(celery_app, CeleryApp) diff --git a/test/unit/test_health.py b/test/unit/test_health.py index ec938b7..8901cbf 100644 --- a/test/unit/test_health.py +++ b/test/unit/test_health.py @@ -1,3 +1,6 @@ -def test_health_route(client): +"""Test route/controller for healthchecks.""" + +def test_health_default_route(client): + """Test default healthckec route.""" response = client.get("/health") - assert response.json["default"]["success"] == True + assert response.json["default"]["success"] is True diff --git a/test/unit/test_root.py b/test/unit/test_root.py index 747b034..e94fca3 100644 --- a/test/unit/test_root.py +++ b/test/unit/test_root.py @@ -1,3 +1,6 @@ +"""Test application root route/controller.""" + def test_root_route(client): + """Ensure the expected message gets returned from the application root.""" response = client.get("/") assert b"Goodbye Doggy!" in response.data From c2de13d8c6ca0b43a29c71d4e00a7ee970580f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20A=2E=20Matienzo?= Date: Mon, 14 Sep 2026 11:39:19 -0700 Subject: [PATCH 9/9] address last PR feedback --- .gitignore | 2 +- quiabo/celery.py | 6 +++--- test/unit/test_health.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 174c4d0..2abc6e4 100644 --- a/.gitignore +++ b/.gitignore @@ -219,4 +219,4 @@ __marimo__/ # other stuff artifacts/* -uv.lock \ No newline at end of file +uv.lock diff --git a/quiabo/celery.py b/quiabo/celery.py index ced5443..6d016b3 100644 --- a/quiabo/celery.py +++ b/quiabo/celery.py @@ -12,7 +12,7 @@ class FlaskTask(Task): """ Class used to provide access to Celery decoratorss. - :see https://flask.palletsprojects.com/en/stable/patterns/celery/ + :see: https://flask.palletsprojects.com/en/stable/patterns/celery/ """ def __call__(self, *args: object, **kwargs: object) -> object: """ @@ -21,9 +21,9 @@ def __call__(self, *args: object, **kwargs: object) -> object: :param args: positional arguments to get passed in the call :type args: object - :param kwargs: keyword arguments to get passed in the call: + :param kwargs: keyword arguments to get passed in the call :type kwargs: object - :return: The output of the task to be run. + :return: The output of the task to be run. :rtype: Object """ with app.app_context(): diff --git a/test/unit/test_health.py b/test/unit/test_health.py index 8901cbf..b38beed 100644 --- a/test/unit/test_health.py +++ b/test/unit/test_health.py @@ -1,6 +1,6 @@ """Test route/controller for healthchecks.""" def test_health_default_route(client): - """Test default healthckec route.""" + """Test default healthcheck route.""" response = client.get("/health") assert response.json["default"]["success"] is True