From 43fc2d34e35fa9d20f39718c70ba1f9b46f576dc Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Wed, 21 Oct 2026 17:28:07 +0100 Subject: [PATCH 1/8] first commit --- .github/CODEOWNERS | 2 + .github/dependabot.yml | 13 ++ .github/labeler.yml | 48 ++++++ .github/pull_request_template.md | 8 + .github/release.yaml | 25 ++++ .github/workflows/labeler.yml | 20 +++ .github/workflows/lint-actions.yml | 22 +++ backend/.env.example | 38 +++++ backend/.gitignore | 138 ++++++++++++++++++ backend/Dockerfile | 29 ++++ .../apps/shredder/src/shredder/__init__.py | 33 +++++ .../apps/shredder/src/shredder/settings.py | 23 +++ backend/apps/shredder/src/shredder/tasks.py | 99 +++++++++++++ backend/apps/shredder/src/shredder/utils.py | 23 +++ backend/apps/shredder/tests/__init__.py | 0 backend/docker-compose.yml | 21 +++ backend/etc/run_server.sh | 41 ++++++ backend/pyproject.toml | 134 +++++++++++++++++ 18 files changed, 717 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/labeler.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/release.yaml create mode 100644 .github/workflows/labeler.yml create mode 100644 .github/workflows/lint-actions.yml create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/apps/shredder/src/shredder/__init__.py create mode 100644 backend/apps/shredder/src/shredder/settings.py create mode 100644 backend/apps/shredder/src/shredder/tasks.py create mode 100644 backend/apps/shredder/src/shredder/utils.py create mode 100644 backend/apps/shredder/tests/__init__.py create mode 100644 backend/docker-compose.yml create mode 100644 backend/etc/run_server.sh create mode 100644 backend/pyproject.toml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..634cdf4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default code owners +* @d-hickey @pohuigin diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..224bd3e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/backend/" + schedule: + interval: "monthly" + open-pull-requests-limit: 20 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 20 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..372b854 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,48 @@ +# File types +documentation: +- changed-files: + - any-glob-to-any-file: ['**/*.md', '**/*.mdx'] + +localization: +- changed-files: + - any-glob-to-any-file: ['**/localization/**/*'] + +test: +- changed-files: + - any-glob-to-any-file: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'] + + +# Languages +python: +- changed-files: + - any-glob-to-any-file: ['**/*.py'] + +typescript: +- changed-files: + - any-glob-to-any-file: ['**/*.ts'] + +react: +- changed-files: + - any-glob-to-any-file: ['**/*.tsx'] + +c-sharp: +- changed-files: + - any-glob-to-any-file: ['**/*.cs'] + +yaml: +- changed-files: + - any-glob-to-any-file: ['**/*.yml'] + + +# Functional areas +backend: +- changed-files: + - any-glob-to-any-file: ['backend/**/*'] + +frontend: +- changed-files: + - any-glob-to-any-file: ['frontend/**/*'] + +github actions: +- changed-files: + - any-glob-to-any-file: ['.github/**/*'] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d337156 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## What? + +... + +For more background, see ticket +#L3D-123. + +## Testing diff --git a/.github/release.yaml b/.github/release.yaml new file mode 100644 index 0000000..8cf0c0e --- /dev/null +++ b/.github/release.yaml @@ -0,0 +1,25 @@ +changelog: + categories: + - title: ๐Ÿšจ Breaking Changes + labels: + - breaking change + + - title: โœจ Features + labels: + - feature + + - title: ๐Ÿ› Bugs + labels: + - bug + + - title: ๐Ÿ“‹ Tasks + labels: + - task + + - title: ๐Ÿ“‘ Change Requests + labels: + - CR + + - title: ๐Ÿ“ฆ Other Changes + labels: + - "*" \ No newline at end of file diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..13d5e7c --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,20 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler + +name: "PR Labeler ๐Ÿท๏ธ" +on: [pull_request] + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v6 + with: + sync-labels: true diff --git a/.github/workflows/lint-actions.yml b/.github/workflows/lint-actions.yml new file mode 100644 index 0000000..3b92ae7 --- /dev/null +++ b/.github/workflows/lint-actions.yml @@ -0,0 +1,22 @@ +# This workflow will lint github actions workflows +# on pull requests that modify the .github directory. + +name: "Workflow linter ๐Ÿงน" +on: + workflow_call: + +jobs: + lint: + name: Lint Workflows + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + - uses: actions/setup-go@v5 + with: + go-version: "1.24.0" # The Go version to download (if necessary) and use. + cache: true + - run: go install github.com/rhysd/actionlint/cmd/actionlint@latest + - run: actionlint diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..29f2978 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,38 @@ +# Deploy +DEBUG=1 +ENV_TYPE=local +SERVICE_NAME=shredder_app + +# Build +PYTHON_VERSION=3.13 +PYTHONPATH=./apps/shredder/src +ENV_DIR=.venv +UV_PROJECT_ENVIRONMENT=${ENV_DIR} + +# Docker +DOCKER_COMPOSE_PROJECT=shredder +COMPOSE_ENV_FILES=.env +DOCKER_NETWORK_NAME=backend + +# Procrastinate +PROCRASTINATE_APP=shredder.app +PROCRASTINATE_WORKER=shredder_worker +PROCRASTINATE_TASK_PATHS=shredder.tasks +PROCRASTINATE_QUEUE=shredder_queue +# Specify how many jobs are handled at once by the Procrastinate worker +# (depends on max threads and also how much downtime is expected in the job flow: I/O, network latency, etc.) +PROCRASTINATE_CONCURRENCY=4 +# Postgres DB Connection +PROCRASTINATE_DB_HOST=127.0.0.1 +PROCRASTINATE_DB_PORT=5432 +PROCRASTINATE_DB_USER=db_admin +PROCRASTINATE_DB_PASSWORD=db_password +PROCRASTINATE_DB_NAME=shredder_procrastinate_db + +# S3 Storage Configuration (optional) +# STORAGE_ACCESS_ID= +# STORAGE_SECRET_KEY= +# STORAGE_BUCKET_NAME= +# STORAGE_ENDPOINT_URL= +# STORAGE_ADDRESSING_STYLE= +# STORAGE_DEFAULT_ACL= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..cccd8fa --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,138 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +/static +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env_python* +.envrc +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# PyCharm +.idea/ + +# Local file system storage for running the env +/documents/* \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f66f96f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,29 @@ +ARG base_version=pdf2htmlex/pdf2htmlex:0.18.8.rc2-master-20200820-alpine-3.12.0-x86_64 +FROM ${base_version} + +COPY .env ./apps/ +COPY pyproject.toml ./apps/ +COPY uv.lock ./apps/ +COPY ./etc ./apps/ +COPY ./apps/shredder/src/shredder ./apps/shredder + +RUN apt-get update && apt-get --no-install-recommends install -y \ + postgresql-client \ + curl \ + ttfautohint \ + && apt-get clean && rm -rf /var/lib/apt/lists/* && \ + addgroup --gid 1000 appgroup && adduser --uid 1000 --system --gid 1000 appuser + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + ~/.local/bin/uv venv --allow-existing --python $(PYTHON_VERSION) $(ENV_DIR) + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +ENV PATH="/.venv/bin:$PATH" +RUN uv sync --locked + +# Set exec permission for the startup scripts. +RUN find /apps/etc -name '*.sh' -exec chmod +x {} + + +CMD ["bash", "-c", "exec /apps/etc/run_server.sh"] diff --git a/backend/apps/shredder/src/shredder/__init__.py b/backend/apps/shredder/src/shredder/__init__.py new file mode 100644 index 0000000..96ba5c6 --- /dev/null +++ b/backend/apps/shredder/src/shredder/__init__.py @@ -0,0 +1,33 @@ +import os +import procrastinate +from procrastinate.manager import JobManager + +import shredder.settings as settings +import shredder.utils as utils + +WORKERS = { + settings.PROCRASTINATE_WORKER: {"queues": settings.PROCRASTINATE_QUEUES.split(",")}, +} + +DB_CONFIG_FSTRING = "postgres://{user}:{password}@{host}:{port}/{dbname}" + +PG_CONNECTOR = procrastinate.PsycopgConnector( + conninfo=DB_CONFIG_FSTRING.format( + user=os.environ["PROCRASTINATE_DB_USER"], + password=os.environ["PROCRASTINATE_DB_PASSWORD"], + host=os.getenv("PROCRASTINATE_DB_HOST", "127.0.0.1"), + port=os.environ["PROCRASTINATE_DB_PORT"], + dbname=os.environ["PROCRASTINATE_DB_NAME"], + ) +) + +JOB_MANAGER = JobManager(connector=PG_CONNECTOR) + +task_path_list = settings.PROCRASTINATE_TASK_PATHS + +app = procrastinate.App(import_paths=task_path_list, connector=PG_CONNECTOR) + +__all__ = [ + "settings", + "utils", +] diff --git a/backend/apps/shredder/src/shredder/settings.py b/backend/apps/shredder/src/shredder/settings.py new file mode 100644 index 0000000..b41cc5c --- /dev/null +++ b/backend/apps/shredder/src/shredder/settings.py @@ -0,0 +1,23 @@ +import os + +MAX_JOB_TTL = 600 # seconds. maximum time a job is allowed to run before being force-failed + +PROCRASTINATE_QUEUE = os.getenv("PROCRASTINATE_QUEUE", "shredder_queue") +PROCRASTINATE_WORKER = os.getenv("PROCRASTINATE_WORKER", "shredder_worker") +PROCRASTINATE_TASK_PATHS = os.environ["PROCRASTINATE_TASK_PATHS"].split(",") + + +PROCRASTINATE_DB_USER = os.environ["PROCRASTINATE_DB_USER"], +PROCRASTINATE_DB_PASSWORD = os.environ["PROCRASTINATE_DB_PASSWORD"], +PROCRASTINATE_DB_HOST = os.getenv("PROCRASTINATE_DB_HOST", "127.0.0.1"), +PROCRASTINATE_DB_PORT = os.environ["PROCRASTINATE_DB_PORT"], +PROCRASTINATE_DB_NAME = os.environ["PROCRASTINATE_DB_NAME"], + +# S3_CONFIG = { +# "STORAGE_ACCESS_ID": os.getenv("STORAGE_ACCESS_ID"), +# "STORAGE_SECRET_KEY": os.getenv("STORAGE_SECRET_KEY"), +# "STORAGE_BUCKET_NAME": os.getenv("STORAGE_BUCKET_NAME", "my-bucket"), +# "STORAGE_ENDPOINT_URL": os.getenv("S3_ENDPOINT_URL", "localhost"), +# "STORAGE_ADDRESSING_STYLE": os.getenv("S3_ADDRESSING_STYLE", "path"), +# "STORAGE_DEFAULT_ACL": os.getenv("DEFAULT_ACL"), +# } diff --git a/backend/apps/shredder/src/shredder/tasks.py b/backend/apps/shredder/src/shredder/tasks.py new file mode 100644 index 0000000..e7c3483 --- /dev/null +++ b/backend/apps/shredder/src/shredder/tasks.py @@ -0,0 +1,99 @@ +import asyncio +import logging +import os +import subprocess +import tempfile +import requests +import zipfile + +import shredder +from shredder import settings +from shredder import utils + +logger = logging.getLogger(__name__) + +# async def get_doc_from_url(doc_path: str) -> str: +# """ +# Downloads the document from the given URL and returns the local file path. +# """ +# return requests.get(doc_path).content + + +# async def get_doc_from_s3(doc_path: str) -> str: +# """ +# Downloads the document from the given URL and returns the local file path. +# """ +# import boto3 +# boto3.client +# return ... + + +# DOC_PROVIDER = { +# "url": get_doc_from_url, +# "s3": get_doc_from_s3, +# } + +async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): + """ + Handles the conversion of a PDF document to HTML using pdf2htmlEX. + - Downloads the document from the specified provider + - Runs the conversion + - Stores the output in a temporary directory + - Zips the output HTML files + - Sends the result to the callback URL + """ + + # Generate a temp file to store the downloaded PDF and a temp directory for the output HTML files + with ( + tempfile.NamedTemporaryFile(suffix=".pdf") as tff, + tempfile.TemporaryDirectory() as tdir + ): + # Download the document from the specified provider and write it to the temp file + # tff.write(await DOC_PROVIDER[doc_provider](doc_path)) + tff.write(requests.get(doc_path).content) + + # Run the PDF to HTML conversion command in a subprocess + command = [ + "pdf2htmlEX", + "--embed", "cfijo", + "--split-pages", "1", + "--dest-dir", tdir, + "--page-filename", f"{job_id}-%d.page", + tff.name, + ] + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode: + raise subprocess.CalledProcessError( + process.returncode, command, output=stdout, stderr=stderr + ) + + # Zip the output HTML files + with zipfile.ZipFile(f"{tdir}/{job_id}.zip", "w") as zipf: + for root, _, files in os.walk(tdir): + for file in files: + zipf.write(os.path.join(root, file), arcname=file) + + # Post the zipped result to the callback URL + response = requests.post(call_back_url, files={"file": open(f"{tdir}/{job_id}.zip", "rb")}) + logger.info("Posted result to callback URL: %s, response: %s", call_back_url, response.status_code) + + +@shredder.app.task(queue=settings.PROCRASTINATE_QUEUE, pass_context=True) +async def convert_pdf_to_html( + job_context, doc_path: str, call_back_url: str, doc_provider: str = "url" +): + """ + - ... + """ + job_id = job_context.job_id + + @utils.set_timeout(settings.MAX_JOB_TTL) + async def _run_job(*args): + return await handle_conversion(*args) + + return await _run_job(job_id, doc_path, call_back_url, doc_provider) diff --git a/backend/apps/shredder/src/shredder/utils.py b/backend/apps/shredder/src/shredder/utils.py new file mode 100644 index 0000000..d36e4cf --- /dev/null +++ b/backend/apps/shredder/src/shredder/utils.py @@ -0,0 +1,23 @@ +import socket +from functools import wraps +import asyncio + +def worker_identity(job_context) -> dict: + """Which machine and worker are running this job. worker_id says nothing about the VM.""" + return {"machine": socket.gethostname(), "worker_name": job_context.worker_name} + + +def set_timeout(seconds): + """ + Decorator that enforces a timeout on an async function. + Raises asyncio.TimeoutError if the function exceeds the timeout. + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds) + + return wrapper + + return decorator diff --git a/backend/apps/shredder/tests/__init__.py b/backend/apps/shredder/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..4e9d1ba --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,21 @@ +name: shredder + +services: + app: + build: + context: ${PWD} + dockerfile: docker/Dockerfile + env_file: .env + environment: + DJANGO_STATIC_ROOT: "/apps/static/" + PYTHONPATH: /apps/research_tool/src:/apps/research_worker/src + volumes: + - ${PWD}apps/shredder/src:/apps/shredder/src + networks: + - backend + +networks: + backend: + external: true + name: ${DOCKER_NETWORK_NAME} + driver: bridge diff --git a/backend/etc/run_server.sh b/backend/etc/run_server.sh new file mode 100644 index 0000000..a378e0c --- /dev/null +++ b/backend/etc/run_server.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# Re-exec under bash if something invoked this script with a different shell (e.g. a container/task command override using sh). +if [ -z "$BASH_VERSION" ]; then + exec bash "$0" "$@" +fi + +set -e + +# When running locally, .env gets exported into the environment through make file and/or docker-compose. +# When deploying to cloud, source the generated .env file +if [ "$ENV_TYPE" = "remote" ] && [ -f /apps/.env ]; then + set -o allexport + . /apps/.env + set +o allexport +fi + +export PYTHONPATH=/apps + +pids=() +# Forward SIGTERM/SIGINT to all worker processes so Procrastinate can shut down gracefully when container is stopped/rebooted +term_handler() { + for pid in "${pids[@]}"; do + kill -TERM "$pid" 2>/dev/null || true + done + wait + exit 0 +} +trap term_handler SIGTERM SIGINT + +echo "Starting worker..." + +# Run 1 ingest workers running a max of WORKER_CONCURRENCY at a time with WORKER_QUEUES queues +WORKER_QUEUES="${PROCRASTINATE_QUEUES:?PROCRASTINATE_QUEUES is not set}" +WORKER_CONCURRENCY="${PROCRASTINATE_CONCURRENCY:?PROCRASTINATE_CONCURRENCY is not set}" +procrastinate --log-format "%(asctime)s %(levelname)-7s %(name)s %(message)s" worker -q "$WORKER_QUEUES" -c "$WORKER_CONCURRENCY" & +pids+=($!) + +# Wait for all background jobs; if any exits, script exits too +wait -n +exit $? diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..7c107fb --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,134 @@ +[project] +name = "propylon-shredder" +version = "0.1.0" +description = "Service to run PDF2HTMLEX library using Procrastinate." +requires-python = ">=3.13.11, <3.14" +dependencies = [ + "psycopg2-binary>=2.9.0", + "procrastinate>=3.8.1", + "sqlalchemy>=2.0.44", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + +[build-system] +requires = ["uv_build"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "shredder" +module-root = "apps/shredder/src" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = [".", "apps/shredder/src"] +testpaths = ["apps/shredder/tests"] + +# Ruff configuration +[tool.ruff] +target-version = "py313" +line-length = 100 +indent-width = 4 +src = ["apps/shredder/src", "apps/shredder/tests"] +extend-exclude = [] # e.g. ["*.ipynb"] + +lint.select = [ + # --- Lean baseline --- + # Core correctness & style (pycodestyle / pyflakes) + "E", "W", "F", + # Imports ordering & cleanups + "I", + # Safe modernizations (pyupgrade) + "UP", + # Potential bugs / unsafe patterns + "B", + # Comprehension clarity + "C4", + # Phased expansion candidates (uncomment when ready): + # "RUF" - ruff's own highโ€‘value internal rules + "RUF003", + "RUF005", + "RUF013", + "RUF017", + "RUF018", + "RUF019", + "RUF021", + "RUF022", + "RUF010", + "N", # naming consistency + "A", # builtins shadowing + "ARG", # unused function args + "ERA", # eradicate dead commented code + "TID", # tidy imports (import star / cyclic) + "SIM", # simplify constructs + "TRY", # exception handling patterns + "DTZ", # timezone-aware datetimes + "RET", # consistent returns + "PLR", # refactor metrics (complexity/too many branches) + "PTH", # prefer pathlib over os.path + "G", # logging format issues + "PGH" # invalid escape sequences, highlighting +] + +# Ignore the same rules as current flake8 config +lint.ignore = [ + # "B" - flake8-bugbear + # "B009", # Avoid calling getattr with a constant attribute name + # "B018", # Ignore useless expressions that have no effect + # "B904", # Don't require 'raise ... from err' inside except blocks + + # C - flake8-comprehensions and others + # "C403", # Ignore list comprehensions that could be set comprehensions + + # DJ - Django related issues to ignore + # "DJ001", # Checks nullable string-based fields (like CharField and TextField) in Django models. + + # E - pycodestyle errors + # "E203", # Ignore whitespace before punctuation (conflicts with formatter) + # "E501", # Ignore line length (handled by formatter) + + # RUF - Ruff-specific rules + # "RUF012", # Mutable class attributes - conflicts with Django patterns (Admin, Models, migrations) + # "RUF001", # Ambiguous unicode characters in strings - intentional for legal text, quote conversion, and character mappings + + # UP - pyupgrade + # "UP035", # Ignore deprecated imports from collections.abc + + # "ARG001", + # "ARG002", + # "ERA001", + # "PGH003", + # "PGH004", + # "PLR2004", + # "TRY003", + # "TRY300", + # "TRY401", +] + +lint.fixable = ["ALL"] + +# Ruff formatter configuration - replaces black +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.isort] +combine-as-imports = true +known-first-party = ["shredder", "tests"] + +[tool.ruff.lint.isort.sections] + +[tool.ty.rules] +# possibly-missing-attribute = "ignore" +# invalid-argument-type ="ignore" +# unresolved-attribute = "ignore" + +[tool.ty.src] +include = ["apps/shredder/src", "apps/shredder/tests"] +exclude = [] From 8a33a0051135982720a921d48257378b1cb72a1e Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Thu, 22 Oct 2026 15:09:06 +0100 Subject: [PATCH 2/8] working Dockerfile --- backend/.env.example | 2 +- backend/Dockerfile | 90 +++++-- backend/Makefile | 84 ++++++ backend/docker-compose.yml | 10 +- backend/pyproject.toml | 13 +- .../shredder/src/shredder/__init__.py | 4 +- .../shredder/src/shredder/settings.py | 0 .../{apps => }/shredder/src/shredder/tasks.py | 30 ++- .../{apps => }/shredder/src/shredder/utils.py | 3 +- backend/{apps => }/shredder/tests/__init__.py | 0 backend/uv.lock | 242 ++++++++++++++++++ 11 files changed, 434 insertions(+), 44 deletions(-) create mode 100644 backend/Makefile rename backend/{apps => }/shredder/src/shredder/__init__.py (92%) rename backend/{apps => }/shredder/src/shredder/settings.py (100%) rename backend/{apps => }/shredder/src/shredder/tasks.py (77%) rename backend/{apps => }/shredder/src/shredder/utils.py (99%) rename backend/{apps => }/shredder/tests/__init__.py (100%) create mode 100644 backend/uv.lock diff --git a/backend/.env.example b/backend/.env.example index 29f2978..385bc98 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,7 +5,7 @@ SERVICE_NAME=shredder_app # Build PYTHON_VERSION=3.13 -PYTHONPATH=./apps/shredder/src +PYTHONPATH=./shredder/src ENV_DIR=.venv UV_PROJECT_ENVIRONMENT=${ENV_DIR} diff --git a/backend/Dockerfile b/backend/Dockerfile index f66f96f..6d3950a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,29 +1,81 @@ -ARG base_version=pdf2htmlex/pdf2htmlex:0.18.8.rc2-master-20200820-alpine-3.12.0-x86_64 -FROM ${base_version} +# TODO: Make the first stage more dynamic +# Currently python version is hard-coded -COPY .env ./apps/ -COPY pyproject.toml ./apps/ -COPY uv.lock ./apps/ -COPY ./etc ./apps/ -COPY ./apps/shredder/src/shredder ./apps/shredder +ARG base_image=pdf2htmlex/pdf2htmlex:0.18.8.rc2-master-20200820-alpine-3.12.0-x86_64 +# ARG PYTHON_BUILD_STANDALONE_VERSION=20250115 +# ARG PYTHON_VERSION_MINOR=3.13.1 -RUN apt-get update && apt-get --no-install-recommends install -y \ - postgresql-client \ - curl \ - ttfautohint \ - && apt-get clean && rm -rf /var/lib/apt/lists/* && \ - addgroup --gid 1000 appgroup && adduser --uid 1000 --system --gid 1000 appuser +# ################################################################################################# +# Stage 1: Build Image - Grab a prebuilt, portable Python 3.13 (musl/Alpine-compatible) +FROM ${base_image} AS buid-image +# FROM --platform=$BUILDPLATFORM alpine:3.20 AS buid-image -RUN curl -LsSf https://astral.sh/uv/install.sh | sh - ~/.local/bin/uv venv --allow-existing --python $(PYTHON_VERSION) $(ENV_DIR) +# Download Python +ARG PYTHON_VERSION +RUN echo "PYTHON VERSION ${PYTHON_VERSION:?PYTHON_VERSION must be set}" +ARG PYTHON_BUILD_LIST_URL=https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest + +RUN apk update +RUN apk add --no-cache curl +RUN apk add --no-cache jq + +RUN DOWNLOAD_URL=$(curl -sSL "$PYTHON_BUILD_LIST_URL" | \ + jq -r '.assets[] | select(.name | contains("cpython-'"$PYTHON_VERSION"'") and contains("x86_64-unknown-linux-musl-install_only_stripped")) | .browser_download_url') \ + && curl -sSL $DOWNLOAD_URL -o /tmp/python.tar.gz + +# RUN curl -L -o /tmp/python.tar.gz "$DOWNLOAD_URL" \ +# "https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_BUILD_STANDALONE_VERSION}/cpython-${PYTHON_VERSION_MINOR}+${PYTHON_BUILD_STANDALONE_VERSION}-x86_64-unknown-linux-musl-install_only.tar.gz" \ +RUN mkdir -p /opt/python \ + && tar -xzf /tmp/python.tar.gz -C /opt/python --strip-components=1 + +# ################################################################################################# +# Stage 2: Service Image +FROM ${base_image} AS service-image + +# Copy the APK package registry to avoid re-running apk update +COPY --from=buid-image /var/cache/apk/ /var/cache/apk/ + +# Copy the Python installation +ARG PYTHON_VERSION +COPY --from=buid-image /opt/python /opt/python +ENV PATH="/opt/python/bin:${PATH}" +RUN ln -sf /opt/python/bin/python$PYTHON_VERSION /usr/local/bin/python3 \ + && ln -sf /opt/python/bin/python$PYTHON_VERSION /usr/local/bin/python + +RUN python3 --version + +COPY .env /propylon/ +COPY pyproject.toml /propylon/ +COPY uv.lock /propylon/ +COPY ./etc /propylon/ +COPY ./shredder/src /propylon/shredder/src + +RUN set -o allexport && source /propylon/.env && set +o allexport + +# RUN apk update +# RUN apk add --no-cache python3 py3-pip python3-dev build-base +# RUN apk add --no-cache python3 +# RUN apk add --no-cache postgresql-client +# RUN apk add --no-cache curl + +# Recommended by PDF2HTMLEX to get better results. Add to subprocess calls +# RUN apk add ttfautohint && \ + +# RUN addgroup --gid 1000 appgroup && adduser --uid 1000 --system --gid 1000 appuser + +#RUN curl -LsSf https://astral.sh/uv/install.sh | sh +#RUN /root/.local/bin/uv venv --allow-existing --python "${PYTHON_VERSION:?PYTHON_VERSION must be set}" /.venv +RUN pip install uv +RUN uv venv --allow-existing --python "$PYTHON_VERSION" /propylon/.venv ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 +ENV PATH="/propylon/.venv/bin:$PATH" -ENV PATH="/.venv/bin:$PATH" -RUN uv sync --locked +WORKDIR /propylon/ +RUN uv sync --locked --no-dev # Set exec permission for the startup scripts. -RUN find /apps/etc -name '*.sh' -exec chmod +x {} + +# RUN find /propylon/etc -name '*.sh' -exec chmod +x {} + -CMD ["bash", "-c", "exec /apps/etc/run_server.sh"] +CMD ["/bin/sh", "-c", "exec /propylon/etc/run_server.sh"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..1ec36ec --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,84 @@ +# Import all constants from .env file. +# defaults set in the .env file will be applied to the commands below. +include .env +export + +# Allow passing extra args, e.g. make docker -- --build +%: + @: + +UV=~/.local/bin/uv +IN_ENV=$(UV) run + +# ==================== +# Build +# ==================== + +# Running make env does not overwrite the existing venv folder +env: + curl -LsSf https://astral.sh/uv/install.sh | sh + $(UV) venv --allow-existing --python $(PYTHON_VERSION) $(ENV_DIR) + @printf "Environment installed at $(ENV_DIR), run the following command to activate it: \n$(IN_ENV)\n" + +# Build the environment +build: + $(UV) sync --locked --group dev + +env-update: + $(UV) lock --upgrade + +# ==================== +# Clean +# ==================== + +clean: + - @rm -rf ./*.egg-info + - @find ./ -name '*.pyc' -delete + - @find ./ -name '__pycache__' | xargs rm -rf + - @rm -f ./.pytest_coverage.* + +clean-env: clean + - @rm -rf $(ENV_DIR) + +clean-all: clean clean-env + @echo "Removed the virtual environment, build-files, and cached-files." + +# ==================== +# Docker +# ==================== + +docker-network: + -docker network create $(DOCKER_NETWORK_NAME) + +docker: docker-network + $(IN_ENV) docker-compose -p $(DOCKER_COMPOSE_PROJECT) up -d $(filter-out $@,$(MAKECMDGOALS)) + +# ==================== +# Formatting / Linting +# ==================== + +# Runs ruff check and fix using the pyproject.toml config +# TEMP: using unsafe fixes to speed things up +fixup: + $(IN_ENV) $(UV) run ruff check --fix + +lint: + $(IN_ENV) $(UV) run ruff check + $(IN_ENV) $(UV) run ty check + +# ==================== +# Testing +# ==================== + +test: + $(IN_ENV) py.test $(filter-out $@,$(MAKECMDGOALS)) + +test-cov: + COVERAGE_PROCESS_START=pyproject.toml COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage run -m pytest + - COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage combine + COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage report + +test-cov-html: + COVERAGE_PROCESS_START=pyproject.toml COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage run -m pytest + - COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage combine + COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage html diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 4e9d1ba..8e2e1a6 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -4,13 +4,13 @@ services: app: build: context: ${PWD} - dockerfile: docker/Dockerfile + dockerfile: Dockerfile + args: + PYTHON_VERSION: ${PYTHON_VERSION} env_file: .env - environment: - DJANGO_STATIC_ROOT: "/apps/static/" - PYTHONPATH: /apps/research_tool/src:/apps/research_worker/src + command: ["/bin/sh", "-c", "exec /propylon/etc/run_server.sh"] volumes: - - ${PWD}apps/shredder/src:/apps/shredder/src + - ${PWD}/shredder/src:/propylon/shredder/src networks: - backend diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7c107fb..e509c22 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,6 @@ version = "0.1.0" description = "Service to run PDF2HTMLEX library using Procrastinate." requires-python = ">=3.13.11, <3.14" dependencies = [ - "psycopg2-binary>=2.9.0", "procrastinate>=3.8.1", "sqlalchemy>=2.0.44", ] @@ -16,24 +15,24 @@ dev = [ ] [build-system] -requires = ["uv_build"] build-backend = "uv_build" +requires = ["uv_build>=0.10.1,<0.11.0"] [tool.uv.build-backend] module-name = "shredder" -module-root = "apps/shredder/src" +module-root = "shredder/src" [tool.pytest.ini_options] asyncio_mode = "auto" -pythonpath = [".", "apps/shredder/src"] -testpaths = ["apps/shredder/tests"] +pythonpath = [".", "shredder/src"] +testpaths = ["shredder/tests"] # Ruff configuration [tool.ruff] target-version = "py313" line-length = 100 indent-width = 4 -src = ["apps/shredder/src", "apps/shredder/tests"] +src = ["shredder/src", "shredder/tests"] extend-exclude = [] # e.g. ["*.ipynb"] lint.select = [ @@ -130,5 +129,5 @@ known-first-party = ["shredder", "tests"] # unresolved-attribute = "ignore" [tool.ty.src] -include = ["apps/shredder/src", "apps/shredder/tests"] +include = ["shredder/src", "shredder/tests"] exclude = [] diff --git a/backend/apps/shredder/src/shredder/__init__.py b/backend/shredder/src/shredder/__init__.py similarity index 92% rename from backend/apps/shredder/src/shredder/__init__.py rename to backend/shredder/src/shredder/__init__.py index 96ba5c6..03708dc 100644 --- a/backend/apps/shredder/src/shredder/__init__.py +++ b/backend/shredder/src/shredder/__init__.py @@ -1,9 +1,9 @@ import os + import procrastinate from procrastinate.manager import JobManager -import shredder.settings as settings -import shredder.utils as utils +from shredder import settings, utils WORKERS = { settings.PROCRASTINATE_WORKER: {"queues": settings.PROCRASTINATE_QUEUES.split(",")}, diff --git a/backend/apps/shredder/src/shredder/settings.py b/backend/shredder/src/shredder/settings.py similarity index 100% rename from backend/apps/shredder/src/shredder/settings.py rename to backend/shredder/src/shredder/settings.py diff --git a/backend/apps/shredder/src/shredder/tasks.py b/backend/shredder/src/shredder/tasks.py similarity index 77% rename from backend/apps/shredder/src/shredder/tasks.py rename to backend/shredder/src/shredder/tasks.py index e7c3483..b0a0bcb 100644 --- a/backend/apps/shredder/src/shredder/tasks.py +++ b/backend/shredder/src/shredder/tasks.py @@ -3,12 +3,13 @@ import os import subprocess import tempfile -import requests import zipfile +from pathlib import Path + +import requests import shredder -from shredder import settings -from shredder import utils +from shredder import settings, utils logger = logging.getLogger(__name__) @@ -43,9 +44,10 @@ async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): - Sends the result to the callback URL """ - # Generate a temp file to store the downloaded PDF and a temp directory for the output HTML files + # Generate a temp file to store the downloaded PDF + # and a temp directory for the output HTML files with ( - tempfile.NamedTemporaryFile(suffix=".pdf") as tff, + tempfile.NamedTemporaryFile(suffix=".pdf") as tff, tempfile.TemporaryDirectory() as tdir ): # Download the document from the specified provider and write it to the temp file @@ -73,14 +75,24 @@ async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): ) # Zip the output HTML files - with zipfile.ZipFile(f"{tdir}/{job_id}.zip", "w") as zipf: + with zipfile.ZipFile(f"{tdir}/{job_id}.zip", "w") as zip_ff: for root, _, files in os.walk(tdir): for file in files: - zipf.write(os.path.join(root, file), arcname=file) + this_file_path = Path(root) / file + with this_file_path.open("rb") as this_ff: + zip_ff.write( + this_ff, + arcname=file + ) # Post the zipped result to the callback URL - response = requests.post(call_back_url, files={"file": open(f"{tdir}/{job_id}.zip", "rb")}) - logger.info("Posted result to callback URL: %s, response: %s", call_back_url, response.status_code) + with Path(f"{tdir}/{job_id}.zip").open("rb") as zip_ff: + response = requests.post(call_back_url, files={"file": zip_ff}) + logger.info( + "Posted result to callback URL: %s, response: %s", + call_back_url, + response.status_code + ) @shredder.app.task(queue=settings.PROCRASTINATE_QUEUE, pass_context=True) diff --git a/backend/apps/shredder/src/shredder/utils.py b/backend/shredder/src/shredder/utils.py similarity index 99% rename from backend/apps/shredder/src/shredder/utils.py rename to backend/shredder/src/shredder/utils.py index d36e4cf..d2d12ea 100644 --- a/backend/apps/shredder/src/shredder/utils.py +++ b/backend/shredder/src/shredder/utils.py @@ -1,6 +1,7 @@ +import asyncio import socket from functools import wraps -import asyncio + def worker_identity(job_context) -> dict: """Which machine and worker are running this job. worker_id says nothing about the VM.""" diff --git a/backend/apps/shredder/tests/__init__.py b/backend/shredder/tests/__init__.py similarity index 100% rename from backend/apps/shredder/tests/__init__.py rename to backend/shredder/tests/__init__.py diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..a36117e --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,242 @@ +version = 1 +revision = 3 +requires-python = ">=3.13.11, <3.14" + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "croniter" +version = "6.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "procrastinate" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "attrs" }, + { name = "croniter" }, + { name = "packaging" }, + { name = "psycopg", extra = ["pool"] }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/8a/ab3a9841b38b9349eb3cb871c47d0683ce4811669c9f0fb7b889343bea8f/procrastinate-3.10.0.tar.gz", hash = "sha256:3668da769088682abf91b8c760de467be1203218411872f1a26ac7515d4f980d", size = 91101, upload-time = "2026-09-23T21:22:57.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/51/c7ea18faef04260a1e67cb60c2c815ff0f2792c323c4f3a733478cbfc883/procrastinate-3.10.0-py3-none-any.whl", hash = "sha256:949ad25b4377c30e03a9d0e053d15dfa47ef02b3f12ae8a7f3254686183c152c", size = 155244, upload-time = "2026-09-23T21:22:55.873Z" }, +] + +[[package]] +name = "propylon-shredder" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "procrastinate" }, + { name = "sqlalchemy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "procrastinate", specifier = ">=3.8.1" }, + { name = "sqlalchemy", specifier = ">=2.0.44" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, +] + +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +pool = [ + { name = "psycopg-pool" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dateutil" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/df/f15f80383f152452d8985b36d95cb8751f901a3e0ff4cd27f7e118f168e2/python_dateutil-3.0.0.tar.gz", hash = "sha256:7ebcb3c29a92f3ceafe79a7a48a5c45677e9e004bbeec5742ac0bbb00560b7a2", size = 271066, upload-time = "2026-10-21T23:41:45.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/dc/9addbf490110b5727380059ee542ad8353bdcee45a37ba6a5e0976966ae0/sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52", size = 10589700, upload-time = "2026-10-21T16:25:06.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/db/ecf1cf1f5c8cb7661081dcb0561633c174213d7af5fccc17ab48cbe9812a/sqlalchemy-2.1.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0534d3f41295a74d9fe2e0838e06ce6329a0855b2965df5f5d1c4d3f6f1406d0", size = 2468626, upload-time = "2026-10-21T18:49:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/9b/98/1cccefb4fc94f4efd621604063acbbea4e3d530c27151fd7249d68adabf2/sqlalchemy-2.1.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:650f97996a4c529f4930969f3ba11bcdb57ff160ed1cb87ea0174393bf4f5f73", size = 4602545, upload-time = "2026-10-21T18:54:18.766Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/1817d7406ed92c8647a1d1db965a1bc2dd5a3f80d18e4d28ad62daf9bd72/sqlalchemy-2.1.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bada6bd29ae58a0581bf6977c0a43dfd67388fd09129f1475c37eca370cccd8", size = 4655491, upload-time = "2026-10-21T19:05:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/41/5c/0bdaa1676fb182b93e3c87c09cafd08a734ef29820aac6312b87a88f8537/sqlalchemy-2.1.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96f5fcdfb7e89b6d895e2ddc165f67b3126a585dfdf2a95b0c1f3fcf3380d7d4", size = 4319667, upload-time = "2026-10-21T19:23:16.637Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d7/319e5a049b6f5235d1229858972abb05b8eae2fdd6639dc7bdbbc9cdb68d/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:015bd7b0687b5feaecb9988bc2f36ecb39daca83bd806b094b43b19ea503b0a9", size = 4525975, upload-time = "2026-10-21T18:54:21.553Z" }, + { url = "https://files.pythonhosted.org/packages/19/3b/6e8e5c14a3bcfae59a081a27f33e4f225df8140daab8e90fc3c42afd0d0e/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bf883ae772971bc6155cb64cdfb9e31c50a2099f3b7c8b51b0401bf608a9fe8e", size = 4320900, upload-time = "2026-10-21T19:23:18.835Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a7/7ad220d5ea6308a9a32395785f4ce8beff4d00b0ae56ab9f2dafcca28743/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ff897d13de613768123dee68474956a51fe641c42bba7af6cb7776716a8dbcb", size = 4616756, upload-time = "2026-10-21T19:05:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/08/81/53a198f5a29f247adc68c0c6da69bd2c7bdd89d3ffc58f9e626ee3160f18/sqlalchemy-2.1.6-cp313-cp313-win32.whl", hash = "sha256:9db8bacb2a69b4b5388bc1439463170af28f4cc710933085b81d1658d931d2c2", size = 2382042, upload-time = "2026-10-21T17:11:33.968Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/69e86a23cd9ccef0d654c09dc69dfea6d62b1311d2f13ca3daab1bcd922e/sqlalchemy-2.1.6-cp313-cp313-win_amd64.whl", hash = "sha256:b9f0b92b4a88b630f1e67df816b43cb017bb583479917dd100541d4f490a9517", size = 2432597, upload-time = "2026-10-21T17:11:35.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/90/a7f81395135cc5da724403ece4340f7e04c1ce752da15d0ad337591ec9c9/sqlalchemy-2.1.6-cp313-cp313-win_arm64.whl", hash = "sha256:bc495d00f235c3df6f376187a4ef48a56e162fdcc0a113ee34518332aa1aa2df", size = 2393224, upload-time = "2026-10-21T18:54:18.405Z" }, + { url = "https://files.pythonhosted.org/packages/c1/69/c75534e336b06b3fd3f76de73d89736510800e60c77c58d4e4ce18389018/sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe", size = 2063055, upload-time = "2026-10-21T18:42:10.234Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] From 594d7e1810d060925ee332412a01340ff33264c3 Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Tue, 27 Oct 2026 11:28:13 +0000 Subject: [PATCH 3/8] task needs fixing --- .github/workflows/backend_validate.yml | 76 ++++++++++++++++ .github/workflows/pull_request_verify.yml | 61 +++++++++++++ .github/workflows/release.yml | 68 +++++++++++++++ backend/Dockerfile | 12 ++- backend/docker-compose.yml | 2 + backend/etc/run_server.sh | 39 ++------- backend/pyproject.toml | 7 ++ backend/shredder/src/shredder/__init__.py | 4 - backend/shredder/src/shredder/settings.py | 2 +- backend/shredder/src/shredder/tasks.py | 33 ++++--- backend/uv.lock | 101 +++++++++++++++++++++- 11 files changed, 353 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/backend_validate.yml create mode 100644 .github/workflows/pull_request_verify.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/backend_validate.yml b/.github/workflows/backend_validate.yml new file mode 100644 index 0000000..5673397 --- /dev/null +++ b/.github/workflows/backend_validate.yml @@ -0,0 +1,76 @@ +name: Backend Build, Lint, and Test + +on: + workflow_call: + + # Allow manual trigger + workflow_dispatch: + +jobs: + build: + name: Build and Test + # Switching to self-hosted runner from `runs-on: ubuntu-latest` + # TEMP: runner filtering (we prob need new dedicated runners): + runs-on: [ubuntu-latest] + defaults: + run: + working-directory: ./backend + # Don't run the job if the PR is a draft PR + if: ${{ !github.event.pull_request.draft }} + + services: + postgres: + image: postgres:alpine + env: + POSTGRES_USER: ${{ env.PROCRASTINATE_DB_USER }} + POSTGRES_DB: ${{ env.PROCRASTINATE_DB_NAME }} + POSTGRES_PASSWORD: ${{ env.PROCRASTINATE_DB_PASSWORD }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + PROCRASTINATE_DB_USER: db_admin + PROCRASTINATE_DB_NAME: shredder_procrastinate_db + PROCRASTINATE_DB_PASSWORD: db_password + # Determine whether the workflow was triggered by merge queue + MERGE_QUEUE: ${{ github.event_name == 'merge_group' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Setup Python + uses: astral-sh/setup-uv@v8.0.0 + with: + enable-cache: true + cache-dependency-glob: "./pyproject.toml" + working-directory: "./backend" + prune-cache: false + + - name: Build + run: | + cp .env.example .env + make env + make build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + + - name: Lint + id: run-lint + run: | + make lint + + - name: Run Tests with Coverage + run: | + make test-cov + + - name: Coverage threshold (warn only) + continue-on-error: true + run: | + COVERAGE_FILE=.pytest_coverage ~/.local/bin/uv run coverage report --fail-under=80 diff --git a/.github/workflows/pull_request_verify.yml b/.github/workflows/pull_request_verify.yml new file mode 100644 index 0000000..8a3583f --- /dev/null +++ b/.github/workflows/pull_request_verify.yml @@ -0,0 +1,61 @@ +name: Verify Changes + +# This workflow will run on PR open or update +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + paths: + runs-on: ubuntu-latest + permissions: + pull-requests: read + actions: read + contents: read + # Set job outputs to values from filter step + outputs: + backend: ${{ steps.filter.outputs.backend }} + lint_actions: ${{ steps.filter.outputs.lint_actions }} + steps: + - name: Checkout code + if: github.event_name == 'merge_group' # Run only for merge queue + uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + backend: + - "backend/**" + lint_actions: + - ".github/**" + + # Verify Backend + backend: + needs: paths + uses: ./.github/workflows/backend_validate.yml + if: ${{ needs.paths.outputs.backend == 'true' }} + + # Verify Workflows + lint_actions: + needs: paths + if: ${{ needs.paths.outputs.lint_actions == 'true' }} + uses: ./.github/workflows/lint-actions.yml + + # Verify All Passed + verify_all_passed: + needs: [backend, lint_actions] + runs-on: ubuntu-latest + if: ${{ always() && (failure() || cancelled()) }} + steps: + - name: Fail job + uses: actions/github-script@v8.0.0 + with: + script: core.setFailed('Some checks failed or cancelled.') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ebc0721 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release Backend + +on: + workflow_call: + +jobs: + build-and-push: + environment: ${{ inputs.environment }} + name: Backend Release to GitHub + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + defaults: + run: + working-directory: ./backend + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Generate .env file + run: | + cp .env.example .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker (app) + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/propylon/${{ github.repository }}/backend + + - name: Build and push Docker image (app) + uses: docker/build-push-action@v7 + with: + context: ./backend/. + file: ./backend/Dockerfile + target: migrate_stage + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Extract metadata (tags, labels) for Docker (worker) + id: meta-worker + uses: docker/metadata-action@v6 + with: + images: ghcr.io/propylon/${{ github.repository }}/backend-worker + + - name: Build and push Docker image (worker) + uses: docker/build-push-action@v7 + with: + context: ./backend/. + file: ./backend/Dockerfile + target: worker_stage + push: true + tags: ${{ steps.meta-worker.outputs.tags }} + labels: ${{ steps.meta-worker.outputs.labels }} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 6d3950a..ff46a17 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -41,15 +41,19 @@ COPY --from=buid-image /opt/python /opt/python ENV PATH="/opt/python/bin:${PATH}" RUN ln -sf /opt/python/bin/python$PYTHON_VERSION /usr/local/bin/python3 \ && ln -sf /opt/python/bin/python$PYTHON_VERSION /usr/local/bin/python - +# Validate python RUN python3 --version +# For Procrastinate PG Connector - ctypes.find_library needs libpq's linker files and binutils on musl. +RUN apk add --no-cache postgresql-dev binutils + COPY .env /propylon/ COPY pyproject.toml /propylon/ COPY uv.lock /propylon/ -COPY ./etc /propylon/ -COPY ./shredder/src /propylon/shredder/src +COPY ./etc /propylon/etc/ +COPY ./shredder/src /propylon/shredder/src/ +RUN chmod +x /propylon/etc/run_server.sh RUN set -o allexport && source /propylon/.env && set +o allexport # RUN apk update @@ -78,4 +82,6 @@ RUN uv sync --locked --no-dev # Set exec permission for the startup scripts. # RUN find /propylon/etc -name '*.sh' -exec chmod +x {} + +# Override the original base image entrypoint +ENTRYPOINT [] CMD ["/bin/sh", "-c", "exec /propylon/etc/run_server.sh"] diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 8e2e1a6..b235a1f 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -2,6 +2,8 @@ name: shredder services: app: + container_name: propylon_shredder + hostname: propylon_shredder build: context: ${PWD} dockerfile: Dockerfile diff --git a/backend/etc/run_server.sh b/backend/etc/run_server.sh index a378e0c..12ee5f1 100644 --- a/backend/etc/run_server.sh +++ b/backend/etc/run_server.sh @@ -1,41 +1,18 @@ -#!/usr/bin/env bash - -# Re-exec under bash if something invoked this script with a different shell (e.g. a container/task command override using sh). -if [ -z "$BASH_VERSION" ]; then - exec bash "$0" "$@" -fi +#!/usr/bin/env ash set -e -# When running locally, .env gets exported into the environment through make file and/or docker-compose. -# When deploying to cloud, source the generated .env file -if [ "$ENV_TYPE" = "remote" ] && [ -f /apps/.env ]; then - set -o allexport - . /apps/.env - set +o allexport -fi - -export PYTHONPATH=/apps +set -a +. /propylon/.env +set +a -pids=() -# Forward SIGTERM/SIGINT to all worker processes so Procrastinate can shut down gracefully when container is stopped/rebooted -term_handler() { - for pid in "${pids[@]}"; do - kill -TERM "$pid" 2>/dev/null || true - done - wait - exit 0 -} -trap term_handler SIGTERM SIGINT +export PYTHONPATH=/propylon/shredder/src echo "Starting worker..." # Run 1 ingest workers running a max of WORKER_CONCURRENCY at a time with WORKER_QUEUES queues -WORKER_QUEUES="${PROCRASTINATE_QUEUES:?PROCRASTINATE_QUEUES is not set}" +WORKER_QUEUES="${PROCRASTINATE_QUEUE:?PROCRASTINATE_QUEUE is not set}" WORKER_CONCURRENCY="${PROCRASTINATE_CONCURRENCY:?PROCRASTINATE_CONCURRENCY is not set}" -procrastinate --log-format "%(asctime)s %(levelname)-7s %(name)s %(message)s" worker -q "$WORKER_QUEUES" -c "$WORKER_CONCURRENCY" & -pids+=($!) +exec procrastinate --log-format "%(asctime)s %(levelname)-7s %(name)s %(message)s" worker -q "$WORKER_QUEUES" -c "$WORKER_CONCURRENCY" -# Wait for all background jobs; if any exits, script exits too -wait -n -exit $? +# NOTE: exec lets the worker receive Dockerโ€™s shutdown signals directly diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e509c22..a5eec7b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,6 +5,8 @@ description = "Service to run PDF2HTMLEX library using Procrastinate." requires-python = ">=3.13.11, <3.14" dependencies = [ "procrastinate>=3.8.1", + "psycopg[pool]", + "requests", "sqlalchemy>=2.0.44", ] @@ -18,6 +20,11 @@ dev = [ build-backend = "uv_build" requires = ["uv_build>=0.10.1,<0.11.0"] +[tool.uv] +required-environments = [ + "sys_platform == 'linux' and platform_machine == 'x86_64'", +] + [tool.uv.build-backend] module-name = "shredder" module-root = "shredder/src" diff --git a/backend/shredder/src/shredder/__init__.py b/backend/shredder/src/shredder/__init__.py index 03708dc..ea589cd 100644 --- a/backend/shredder/src/shredder/__init__.py +++ b/backend/shredder/src/shredder/__init__.py @@ -5,10 +5,6 @@ from shredder import settings, utils -WORKERS = { - settings.PROCRASTINATE_WORKER: {"queues": settings.PROCRASTINATE_QUEUES.split(",")}, -} - DB_CONFIG_FSTRING = "postgres://{user}:{password}@{host}:{port}/{dbname}" PG_CONNECTOR = procrastinate.PsycopgConnector( diff --git a/backend/shredder/src/shredder/settings.py b/backend/shredder/src/shredder/settings.py index b41cc5c..fb94e9f 100644 --- a/backend/shredder/src/shredder/settings.py +++ b/backend/shredder/src/shredder/settings.py @@ -9,7 +9,7 @@ PROCRASTINATE_DB_USER = os.environ["PROCRASTINATE_DB_USER"], PROCRASTINATE_DB_PASSWORD = os.environ["PROCRASTINATE_DB_PASSWORD"], -PROCRASTINATE_DB_HOST = os.getenv("PROCRASTINATE_DB_HOST", "127.0.0.1"), +PROCRASTINATE_DB_HOST = os.environ["PROCRASTINATE_DB_HOST"], PROCRASTINATE_DB_PORT = os.environ["PROCRASTINATE_DB_PORT"], PROCRASTINATE_DB_NAME = os.environ["PROCRASTINATE_DB_NAME"], diff --git a/backend/shredder/src/shredder/tasks.py b/backend/shredder/src/shredder/tasks.py index b0a0bcb..c9d20d0 100644 --- a/backend/shredder/src/shredder/tasks.py +++ b/backend/shredder/src/shredder/tasks.py @@ -34,7 +34,7 @@ # "s3": get_doc_from_s3, # } -async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): +async def handle_conversion(job_id, doc_path, call_back_url, doc_provider, metadata): """ Handles the conversion of a PDF document to HTML using pdf2htmlEX. - Downloads the document from the specified provider @@ -56,7 +56,7 @@ async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): # Run the PDF to HTML conversion command in a subprocess command = [ - "pdf2htmlEX", + "/usr/local/bin/pdf2htmlEX", # points to the installed location in the container. "--embed", "cfijo", "--split-pages", "1", "--dest-dir", tdir, @@ -70,9 +70,10 @@ async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): ) stdout, stderr = await process.communicate() if process.returncode: - raise subprocess.CalledProcessError( - process.returncode, command, output=stdout, stderr=stderr - ) + raise Exception(f"{process.returncode} {command} {stdout} {stderr}") + # raise subprocess.CalledProcessError( + # process.returncode, command, output=stdout, stderr=stderr + # ) # Zip the output HTML files with zipfile.ZipFile(f"{tdir}/{job_id}.zip", "w") as zip_ff: @@ -86,10 +87,21 @@ async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): ) # Post the zipped result to the callback URL + data = { + "file": zip_ff, + "job_id": job_id, + "doc_path": doc_path, + "doc_provider": doc_provider, + **(metadata or {}) + } with Path(f"{tdir}/{job_id}.zip").open("rb") as zip_ff: - response = requests.post(call_back_url, files={"file": zip_ff}) + response = requests.post( + call_back_url, + data=data + ) logger.info( - "Posted result to callback URL: %s, response: %s", + "Posted result data: %s to callback URL: %s, response: %s", + str(data), call_back_url, response.status_code ) @@ -97,15 +109,16 @@ async def handle_conversion(job_id, doc_path, call_back_url, doc_provider): @shredder.app.task(queue=settings.PROCRASTINATE_QUEUE, pass_context=True) async def convert_pdf_to_html( - job_context, doc_path: str, call_back_url: str, doc_provider: str = "url" + job_context, doc_path: str, call_back_url: str, doc_provider: str = "url", metadata: dict | None = None ): """ - ... + NOTE: only one doc_provider currently supported: GET from "url" """ - job_id = job_context.job_id + job_id = job_context.job.id @utils.set_timeout(settings.MAX_JOB_TTL) async def _run_job(*args): return await handle_conversion(*args) - return await _run_job(job_id, doc_path, call_back_url, doc_provider) + return await _run_job(job_id, doc_path, call_back_url, doc_provider, metadata) diff --git a/backend/uv.lock b/backend/uv.lock index a36117e..be3a563 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,14 +1,17 @@ version = 1 revision = 3 requires-python = ">=3.13.11, <3.14" +required-markers = [ + "platform_machine == 'x86_64' and sys_platform == 'linux'", +] [[package]] name = "asgiref" -version = "3.12.1" +version = "3.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f5fce14e95fccf53b58e8e2c47cd84d40d105d64073ae1ac75166db150e0/asgiref-3.13.0.tar.gz", hash = "sha256:034cc8cbf72d00553b4be256703ac545120b4e50af13b49dec99da899aeeecb4", size = 52941, upload-time = "2026-10-23T08:13:14.715Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593, upload-time = "2026-10-23T08:13:13.394Z" }, ] [[package]] @@ -20,6 +23,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -41,6 +99,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" }, ] +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "iniconfig" version = "2.3.1" @@ -92,6 +159,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "procrastinate" }, + { name = "psycopg", extra = ["pool"] }, + { name = "requests" }, { name = "sqlalchemy" }, ] @@ -104,6 +173,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "procrastinate", specifier = ">=3.8.1" }, + { name = "psycopg", extras = ["pool"] }, + { name = "requests" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, ] @@ -192,6 +263,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -240,3 +326,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce7 wheels = [ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, ] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] From 92b9c8c6b37337d289e18227147727875d5ef9ea Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Wed, 28 Oct 2026 15:27:35 +0000 Subject: [PATCH 4/8] working container. added test suite --- backend/.env.example | 14 +- backend/.pytest_coverage | Bin 0 -> 53248 bytes backend/Dockerfile | 24 +-- backend/Makefile | 15 +- backend/README.md | 96 ++++++++++ backend/docker-compose.yml | 3 + backend/etc/run_server.sh | 3 + backend/pyproject.toml | 13 +- backend/shredder/src/shredder/settings.py | 22 +-- backend/shredder/src/shredder/tasks.py | 198 ++++++++++++--------- backend/shredder/src/shredder/utils.py | 6 - backend/shredder/tests/mock_server.py | 96 ++++++++++ backend/shredder/tests/static/example.pdf | Bin 0 -> 74656 bytes backend/shredder/tests/test_functional.py | 181 +++++++++++++++++++ backend/shredder/tests/test_integration.py | 130 ++++++++++++++ backend/uv.lock | 170 +++++++++--------- 16 files changed, 753 insertions(+), 218 deletions(-) create mode 100644 backend/.pytest_coverage create mode 100644 backend/README.md create mode 100644 backend/shredder/tests/mock_server.py create mode 100644 backend/shredder/tests/static/example.pdf create mode 100644 backend/shredder/tests/test_functional.py create mode 100644 backend/shredder/tests/test_integration.py diff --git a/backend/.env.example b/backend/.env.example index 385bc98..4f4581e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,17 +22,15 @@ PROCRASTINATE_QUEUE=shredder_queue # Specify how many jobs are handled at once by the Procrastinate worker # (depends on max threads and also how much downtime is expected in the job flow: I/O, network latency, etc.) PROCRASTINATE_CONCURRENCY=4 +# Seconds. Maximum time a job is allowed to run before being force-failed +PROCRASTINATE_MAX_JOB_TTL=600 # Postgres DB Connection PROCRASTINATE_DB_HOST=127.0.0.1 PROCRASTINATE_DB_PORT=5432 +PROCRASTINATE_DB_NAME=shredder_procrastinate_db +# UPDATE ME: PROCRASTINATE_DB_USER=db_admin PROCRASTINATE_DB_PASSWORD=db_password -PROCRASTINATE_DB_NAME=shredder_procrastinate_db -# S3 Storage Configuration (optional) -# STORAGE_ACCESS_ID= -# STORAGE_SECRET_KEY= -# STORAGE_BUCKET_NAME= -# STORAGE_ENDPOINT_URL= -# STORAGE_ADDRESSING_STYLE= -# STORAGE_DEFAULT_ACL= +# PDF2HTMLEX settings (optional) +SHREDDER_CONVERSION_CONFIG=--embed-css,0,--css-filename,pages.css,--embed-outline,1,--process-annotation,1,--decompose-ligature,1,--bg-format,svg diff --git a/backend/.pytest_coverage b/backend/.pytest_coverage new file mode 100644 index 0000000000000000000000000000000000000000..c513a2dd34cf6a711dd6c71ffde2faab208b3b63 GIT binary patch literal 53248 zcmeI4+iM(E9LHyNW_Naz-JEC)ON^nXpeCWY7!>N|p^3KGV!c$Wh!1XOcTcioc4yL= z*)|bHRs}BzeH1|yd{O@ZQIr0nYyGO~U?p4_Rxl7r2b}sv4_w(6nGk14e##m;8G7vxj1V8`;E*Juh16dm20^!sWA8RdCBLTP4%C z>^bgNL|nS=iL#utD2-+68O;h&GlAw~~ zmZkPZ*R)F&;f--~#ln(9dM^5*wXRc}^c`Nc?7&RhvwX|3xmXmXx-ZIW8aO7Q#ev|p zT0^o9NrMiBHKTL2&}mj)qh?e*sys`|*-nhLSy7&vDhE2-^;(gTKTxkN$i|@>;fZ~x zQ_W6weJ3*ZtlMUdwrtwl)|@eN*`i^sx@{eXqO_6UwoQHFaNsBsPoZe}-by6X<}@qG9b8A| zclI0EOWK`8_M&Q)JF`KTk+pf)IONT(eP%L-VY;%TaVIKBnr^ALNo7d~UaUIBc3%rT z#uL;*Xe@|}&DQOb>?)qLq2wG9t~n&byqj6SEm|oqD2aha!0pz@PiK)*(VzWi!!D$A6n%D0H6@gruHMw128a;eMG7#z+3Tox>wRM;#! zbwBW5c^J5-oO_!3h60+$F?%XC8WgAQeTUMUh&~w9CY}S;#zLc9Iby}~Cl-i_lv*>(VRx5}(jxI_MVuinoQ-yyzVNM@ z;Ic@dOyF2YO^=scL7CB98Yo7@Dyy{$#q6b=cnl4#86DRC&39xI7pCM^u9DO8Qm(O0 zHS#-lsEvBmgTuk8;L(*p&5Ui04ijyjwJNjeWVh2OO_xZm&Lx4jFqDVk8RUosyz! znp2uDc*3RiBMI7@B0>G{pTuZ z&Gmn3!;<3le{#Rx7~GIfE$e@M!@^n?+uC}Bbz1)?cI%B?W?df9q7vT!w>8}vX9WQe z009sH0T2KI5C8!X009sHf&YkrrfNz~-v3wGp9*~tKmY_l00ck)1V8`;KmY_l00ck) z1TH`VnwHB1&;PRr74`@FjD5h~VaM4^>=-2=fB*=900@8p2!H?xfB*=900@8p2y6m@ zzO=4pM$!r8_v6c1MN>Xk6{YtggAy+{5)XZ}JUgRIzumh#J&>ZLYf{UvJ@dx)7nBr# z|Eq6?etP|fW7>2+ousUblUFV#4V_}W`nM-C8bvPA?)&S&&}9jVUY|Ji`f2aB7oR)j zymxBXYkxfZ=83~(~_67TteMkuiAOHd&00JNY z0w4eaAOHd&00JNY0_PzC`7?oY literal 0 HcmV?d00001 diff --git a/backend/Dockerfile b/backend/Dockerfile index ff46a17..5db1d3e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,14 +1,11 @@ -# TODO: Make the first stage more dynamic -# Currently python version is hard-coded +# TODO: Make the build-stage dynamic. +# Currently the Python version and image/binary URLs are hard-coded ARG base_image=pdf2htmlex/pdf2htmlex:0.18.8.rc2-master-20200820-alpine-3.12.0-x86_64 -# ARG PYTHON_BUILD_STANDALONE_VERSION=20250115 -# ARG PYTHON_VERSION_MINOR=3.13.1 # ################################################################################################# # Stage 1: Build Image - Grab a prebuilt, portable Python 3.13 (musl/Alpine-compatible) FROM ${base_image} AS buid-image -# FROM --platform=$BUILDPLATFORM alpine:3.20 AS buid-image # Download Python ARG PYTHON_VERSION @@ -23,8 +20,6 @@ RUN DOWNLOAD_URL=$(curl -sSL "$PYTHON_BUILD_LIST_URL" | \ jq -r '.assets[] | select(.name | contains("cpython-'"$PYTHON_VERSION"'") and contains("x86_64-unknown-linux-musl-install_only_stripped")) | .browser_download_url') \ && curl -sSL $DOWNLOAD_URL -o /tmp/python.tar.gz -# RUN curl -L -o /tmp/python.tar.gz "$DOWNLOAD_URL" \ -# "https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_BUILD_STANDALONE_VERSION}/cpython-${PYTHON_VERSION_MINOR}+${PYTHON_BUILD_STANDALONE_VERSION}-x86_64-unknown-linux-musl-install_only.tar.gz" \ RUN mkdir -p /opt/python \ && tar -xzf /tmp/python.tar.gz -C /opt/python --strip-components=1 @@ -56,19 +51,9 @@ COPY ./shredder/src /propylon/shredder/src/ RUN chmod +x /propylon/etc/run_server.sh RUN set -o allexport && source /propylon/.env && set +o allexport -# RUN apk update -# RUN apk add --no-cache python3 py3-pip python3-dev build-base -# RUN apk add --no-cache python3 -# RUN apk add --no-cache postgresql-client -# RUN apk add --no-cache curl - -# Recommended by PDF2HTMLEX to get better results. Add to subprocess calls +# TODO:Recommended by PDF2HTMLEX to get better results. Add to subprocess calls # RUN apk add ttfautohint && \ -# RUN addgroup --gid 1000 appgroup && adduser --uid 1000 --system --gid 1000 appuser - -#RUN curl -LsSf https://astral.sh/uv/install.sh | sh -#RUN /root/.local/bin/uv venv --allow-existing --python "${PYTHON_VERSION:?PYTHON_VERSION must be set}" /.venv RUN pip install uv RUN uv venv --allow-existing --python "$PYTHON_VERSION" /propylon/.venv @@ -79,9 +64,6 @@ ENV PATH="/propylon/.venv/bin:$PATH" WORKDIR /propylon/ RUN uv sync --locked --no-dev -# Set exec permission for the startup scripts. -# RUN find /propylon/etc -name '*.sh' -exec chmod +x {} + - # Override the original base image entrypoint ENTRYPOINT [] CMD ["/bin/sh", "-c", "exec /propylon/etc/run_server.sh"] diff --git a/backend/Makefile b/backend/Makefile index 1ec36ec..574e0e5 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -70,15 +70,22 @@ lint: # Testing # ==================== -test: - $(IN_ENV) py.test $(filter-out $@,$(MAKECMDGOALS)) +test-functional: + $(IN_ENV) py.test -k test_functional + +test-integration: + $(IN_ENV) py.test -k test_integration + +test: test-functional + +test-suite: test-functional test-integration test-cov: - COVERAGE_PROCESS_START=pyproject.toml COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage run -m pytest + COVERAGE_PROCESS_START=pyproject.toml COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage run -m pytest -k test_functional - COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage combine COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage report test-cov-html: - COVERAGE_PROCESS_START=pyproject.toml COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage run -m pytest + COVERAGE_PROCESS_START=pyproject.toml COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage run -m pytest -k test_functional - COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage combine COVERAGE_FILE=.pytest_coverage $(IN_ENV) coverage html diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..469daba --- /dev/null +++ b/backend/README.md @@ -0,0 +1,96 @@ +# Propylon Shredder + +Shredder is a custom Procrastinate worker service that converts PDF files to paginated HTML with +[pdf2htmlEX](https://github.com/pdf2htmlEX/pdf2htmlEX). It reads jobs from +PostgreSQL and POSTs a ZIP of the converted files to each job's callback URL. + +## Run with Docker + +You need Docker, Docker Compose, and `make`. From this directory: + +1. Copy `.env.example` to `.env` and set the PostgreSQL connection values. Replace + the example password. The database must be reachable from the worker container; + `127.0.0.1` refers to the container itself, not your host. +2. Install `uv` and create the Python environment (the Makefile's `env` target + installs `uv` if needed): + + ```sh + make env + make build + ``` + +3. Create a PostgreSQL database for Procrastinate, then apply its schema once: + + ```sh + set -a + . ./.env + set +a + uv run procrastinate --app shredder.app schema --apply + ``` + +4. Start the worker and its Docker network: + + ```sh + make docker + ``` + +The worker listens to the queue set by `PROCRASTINATE_QUEUE`. Shredder does not +start PostgreSQL; provide a database separately and configure its connection in +`.env`. + +## Queue a conversion + +With the `.env` values exported as above, enqueue a job using Procrastinate's CLI: + +```sh +uv run procrastinate --app shredder.app defer shredder.tasks.convert_pdf_to_html \ + '{"doc_path":"https://example.com/input.pdf","call_back_url":"https://example.com/shredder-result","doc_provider":"url","metadata":{"reference":"123"}}' +``` + +`doc_path` must be a URL the worker can GET. `doc_provider` currently supports +only `url`. You can optionally include `call_back_auth`, which is sent as the +callback's `Authorization` header, and `metadata`, which is returned as +additional form fields. + +## Callback + +After conversion, Shredder sends a multipart POST to `call_back_url`. It includes +the ZIP in the `file` field and form fields `job_id`, `doc_path`, and +`doc_provider`, plus any supplied metadata (with fields flattened). The ZIP contains the generated HTML +and related files. +NOTE: do not include field names in the input metadata JSON conflicting with +`file`, `call_back_url`, `job_id`, `doc_path`, `doc_provider`. + +## Configuration + +Set values in `.env` for local Docker runs. The main settings are: + +- `PROCRASTINATE_DB_HOST`, `PROCRASTINATE_DB_PORT`, `PROCRASTINATE_DB_NAME`, + `PROCRASTINATE_DB_USER`, and `PROCRASTINATE_DB_PASSWORD`: PostgreSQL connection. +- `PROCRASTINATE_QUEUE`: queue to consume (default: `shredder_queue`). +- `PROCRASTINATE_CONCURRENCY`: number of jobs processed concurrently (default: `4`). +- `PROCRASTINATE_MAX_JOB_TTL`: maximum job runtime in seconds (default: `600`). +- `SHREDDER_CONVERSION_CONFIG`: optional comma-separated pdf2htmlEX arguments. + +See `.env.example` for the full set of build and worker settings. Do not commit +credentials. + +## Development + +The project requires Python 3.13.11. Install the development dependencies with: + +```sh +make env +make build +``` + +Tests: + +- `make test` runs the functional tests. They mock HTTP and `pdf2htmlEX`, so no + database or container is needed. +- `make test-integration` starts the worker container, queues a real job, and checks + the callback. It needs the database from `.env` reachable on `127.0.0.1` (override + with `TEST_PROCRASTINATE_DB_HOST`/`TEST_PROCRASTINATE_DB_PORT`). The test starts a + mock server on port 1234 that serves `example.pdf` and receives the callback; the + worker reaches it through `host.docker.internal`. +- `make test-suite` runs both. diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index b235a1f..4f56440 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -15,6 +15,9 @@ services: - ${PWD}/shredder/src:/propylon/shredder/src networks: - backend + # Lets the worker reach services on the host, e.g. the integration test mock server. + extra_hosts: + - "host.docker.internal:host-gateway" networks: backend: diff --git a/backend/etc/run_server.sh b/backend/etc/run_server.sh index 12ee5f1..8a72cdc 100644 --- a/backend/etc/run_server.sh +++ b/backend/etc/run_server.sh @@ -2,6 +2,9 @@ set -e +# TODO: TEMP: currently pulling env vars from .env file directly +# In production they should be injected into the container at run time. +# NOTE: Could simply include an empty .env file for remote deployment to avoid clobbering variables. set -a . /propylon/.env set +a diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a5eec7b..1c94287 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,9 +4,9 @@ version = "0.1.0" description = "Service to run PDF2HTMLEX library using Procrastinate." requires-python = ">=3.13.11, <3.14" dependencies = [ + "httpx>=0.28.1", "procrastinate>=3.8.1", "psycopg[pool]", - "requests", "sqlalchemy>=2.0.44", ] @@ -14,6 +14,7 @@ dependencies = [ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", + "coverage", ] [build-system] @@ -138,3 +139,13 @@ known-first-party = ["shredder", "tests"] [tool.ty.src] include = ["shredder/src", "shredder/tests"] exclude = [] + +[tool.coverage.report] +include = [ + "shredder/src/**/*.py" +] +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", +] \ No newline at end of file diff --git a/backend/shredder/src/shredder/settings.py b/backend/shredder/src/shredder/settings.py index fb94e9f..18e35ad 100644 --- a/backend/shredder/src/shredder/settings.py +++ b/backend/shredder/src/shredder/settings.py @@ -1,23 +1,15 @@ import os -MAX_JOB_TTL = 600 # seconds. maximum time a job is allowed to run before being force-failed +PROCRASTINATE_MAX_JOB_TTL = int(os.getenv("PROCRASTINATE_MAX_JOB_TTL", 600)) PROCRASTINATE_QUEUE = os.getenv("PROCRASTINATE_QUEUE", "shredder_queue") PROCRASTINATE_WORKER = os.getenv("PROCRASTINATE_WORKER", "shredder_worker") PROCRASTINATE_TASK_PATHS = os.environ["PROCRASTINATE_TASK_PATHS"].split(",") +PROCRASTINATE_DB_USER = os.environ["PROCRASTINATE_DB_USER"] +PROCRASTINATE_DB_PASSWORD = os.environ["PROCRASTINATE_DB_PASSWORD"] +PROCRASTINATE_DB_HOST = os.environ["PROCRASTINATE_DB_HOST"] +PROCRASTINATE_DB_PORT = os.environ["PROCRASTINATE_DB_PORT"] +PROCRASTINATE_DB_NAME = os.environ["PROCRASTINATE_DB_NAME"] -PROCRASTINATE_DB_USER = os.environ["PROCRASTINATE_DB_USER"], -PROCRASTINATE_DB_PASSWORD = os.environ["PROCRASTINATE_DB_PASSWORD"], -PROCRASTINATE_DB_HOST = os.environ["PROCRASTINATE_DB_HOST"], -PROCRASTINATE_DB_PORT = os.environ["PROCRASTINATE_DB_PORT"], -PROCRASTINATE_DB_NAME = os.environ["PROCRASTINATE_DB_NAME"], - -# S3_CONFIG = { -# "STORAGE_ACCESS_ID": os.getenv("STORAGE_ACCESS_ID"), -# "STORAGE_SECRET_KEY": os.getenv("STORAGE_SECRET_KEY"), -# "STORAGE_BUCKET_NAME": os.getenv("STORAGE_BUCKET_NAME", "my-bucket"), -# "STORAGE_ENDPOINT_URL": os.getenv("S3_ENDPOINT_URL", "localhost"), -# "STORAGE_ADDRESSING_STYLE": os.getenv("S3_ADDRESSING_STYLE", "path"), -# "STORAGE_DEFAULT_ACL": os.getenv("DEFAULT_ACL"), -# } +SHREDDER_CONVERSION_CONFIG = os.getenv("SHREDDER_CONVERSION_CONFIG") diff --git a/backend/shredder/src/shredder/tasks.py b/backend/shredder/src/shredder/tasks.py index c9d20d0..303ddff 100644 --- a/backend/shredder/src/shredder/tasks.py +++ b/backend/shredder/src/shredder/tasks.py @@ -1,40 +1,50 @@ import asyncio +import io import logging import os -import subprocess import tempfile import zipfile from pathlib import Path +from typing import Any -import requests +import httpx import shredder from shredder import settings, utils logger = logging.getLogger(__name__) -# async def get_doc_from_url(doc_path: str) -> str: -# """ -# Downloads the document from the given URL and returns the local file path. -# """ -# return requests.get(doc_path).content +class ConversionError(Exception): + pass -# async def get_doc_from_s3(doc_path: str) -> str: -# """ -# Downloads the document from the given URL and returns the local file path. -# """ -# import boto3 -# boto3.client -# return ... +# TODO: need to inject the client from the local context +async def get_doc_from_url(doc_path: str, local_ctx: dict) -> bytes: + """ + Downloads the document from the given URL and returns its content. + """ + client = local_ctx["http_client"] + response = await client.get(doc_path) + return response.content + +DOC_PROVIDER = { + "url": get_doc_from_url, + # TODO: Add additional providers +} -# DOC_PROVIDER = { -# "url": get_doc_from_url, -# "s3": get_doc_from_s3, -# } -async def handle_conversion(job_id, doc_path, call_back_url, doc_provider, metadata): +def zip_directory(src_dir: str, zip_path: str) -> None: + """ + Zips every file under src_dir into zip_path. Blocking; run it via asyncio.to_thread. + """ + with zipfile.ZipFile(zip_path, "w") as zip_ff: + for root, _, files in os.walk(src_dir): + for file in files: + zip_ff.write(Path(root) / file, arcname=file) + + +async def handle_conversion(job_id, doc_path, call_back_url, call_back_auth, doc_provider, metadata): """ Handles the conversion of a PDF document to HTML using pdf2htmlEX. - Downloads the document from the specified provider @@ -44,81 +54,103 @@ async def handle_conversion(job_id, doc_path, call_back_url, doc_provider, metad - Sends the result to the callback URL """ - # Generate a temp file to store the downloaded PDF - # and a temp directory for the output HTML files - with ( - tempfile.NamedTemporaryFile(suffix=".pdf") as tff, - tempfile.TemporaryDirectory() as tdir - ): - # Download the document from the specified provider and write it to the temp file - # tff.write(await DOC_PROVIDER[doc_provider](doc_path)) - tff.write(requests.get(doc_path).content) - - # Run the PDF to HTML conversion command in a subprocess - command = [ - "/usr/local/bin/pdf2htmlEX", # points to the installed location in the container. - "--embed", "cfijo", - "--split-pages", "1", - "--dest-dir", tdir, - "--page-filename", f"{job_id}-%d.page", - tff.name, + if isinstance(settings.SHREDDER_CONVERSION_CONFIG, str): + conversion_config = settings.SHREDDER_CONVERSION_CONFIG.split(",") + else: + conversion_config = [ + "--embed-css", "0", + "--css-filename", "pages.css", + "--process-annotation", "1", + "--decompose-ligature", "1", + "--bg-format", "svg", ] - process = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - if process.returncode: - raise Exception(f"{process.returncode} {command} {stdout} {stderr}") - # raise subprocess.CalledProcessError( - # process.returncode, command, output=stdout, stderr=stderr - # ) - - # Zip the output HTML files - with zipfile.ZipFile(f"{tdir}/{job_id}.zip", "w") as zip_ff: - for root, _, files in os.walk(tdir): - for file in files: - this_file_path = Path(root) / file - with this_file_path.open("rb") as this_ff: - zip_ff.write( - this_ff, - arcname=file - ) - - # Post the zipped result to the callback URL - data = { - "file": zip_ff, - "job_id": job_id, - "doc_path": doc_path, - "doc_provider": doc_provider, - **(metadata or {}) - } - with Path(f"{tdir}/{job_id}.zip").open("rb") as zip_ff: - response = requests.post( - call_back_url, - data=data + + # Open an asynchronous http client that handles GET and Post requests for the life of the task. + async with httpx.AsyncClient() as http_client: + # Generate a temp file to store the downloaded PDF + # and a temp directory for the output HTML files + with ( + tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tff, # The incoming PDF file + tempfile.TemporaryDirectory(delete=True) as tdir, # Directory for conversion output files + tempfile.TemporaryDirectory(delete=True) as zip_dir, # Directory for the zipped output + ): + # Download the document from the specified provider and write it to the temp file + doc_provider_func = DOC_PROVIDER[doc_provider] + tff.write( + await doc_provider_func(doc_path, local_ctx=locals()) + ) + tff.seek(0) + + # Run the PDF to HTML conversion command in a subprocess + command = [ + "/usr/local/bin/pdf2htmlEX", # points to the installed location in the container. + "--dest-dir", tdir, + *conversion_config, + "--tmp-dir", tdir, + tff.name, + ] + + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - logger.info( - "Posted result data: %s to callback URL: %s, response: %s", - str(data), - call_back_url, - response.status_code - ) + stdout, stderr = await process.communicate() + if process.returncode: + raise ConversionError(f"{process.returncode} {command} {stdout} {stderr}") + + # Zip the output HTML files + # Written outside tdir so the archive does not include itself while tdir is walked. + zip_path = f"{zip_dir}/{job_id}.zip" + await asyncio.to_thread(zip_directory, tdir, zip_path) + + # Post the zipped result to the callback URL + with Path(zip_path).open("rb") as zip_ff: + headers = {} + data = { + "job_id": job_id, + "doc_path": doc_path, + "doc_provider": doc_provider, + **(metadata or {}) + } + if call_back_auth: + headers["Authorization"] = call_back_auth + + response = await http_client.post( + call_back_url, + data=data, + files={"file": (Path(zip_path).name, zip_ff, "application/octet-stream")}, + headers=headers, + ) + zip_ff_len = zip_ff.seek(0) + logger.info( + "Posted result data: %s with file len %d to callback URL: %s, response code: %d", + str(data), + int(zip_ff_len), + str(call_back_url), + int(response.status_code) + ) @shredder.app.task(queue=settings.PROCRASTINATE_QUEUE, pass_context=True) async def convert_pdf_to_html( - job_context, doc_path: str, call_back_url: str, doc_provider: str = "url", metadata: dict | None = None + job_context, doc_path: str, call_back_url: str, call_back_auth: str | None = None, doc_provider: str = "url", metadata: dict | None = None ): """ - - ... - NOTE: only one doc_provider currently supported: GET from "url" + A wrapper task for the conversion function. Allows setting a job time-out. + + Inputs: + - job_context: Automatically supplied by procrastinate when triggered. + - doc_path: The URL pointing to the file to be converted. + - call_back_url: The URL to post the conversion result (zip file) to. + - call_back_auth: Optional `Bearer TOKEN` string for authorization header for the callback endpoint. + - doc_provider: The provider to fetch the document from. Only "url" is currently supported. + - metadata: Optional passed-through metadata dict/JSON to include in the callback. """ job_id = job_context.job.id - @utils.set_timeout(settings.MAX_JOB_TTL) + @utils.set_timeout(settings.PROCRASTINATE_MAX_JOB_TTL) async def _run_job(*args): return await handle_conversion(*args) - return await _run_job(job_id, doc_path, call_back_url, doc_provider, metadata) + return await _run_job(job_id, doc_path, call_back_url, call_back_auth, doc_provider, metadata) diff --git a/backend/shredder/src/shredder/utils.py b/backend/shredder/src/shredder/utils.py index d2d12ea..e93fe2c 100644 --- a/backend/shredder/src/shredder/utils.py +++ b/backend/shredder/src/shredder/utils.py @@ -1,13 +1,7 @@ import asyncio -import socket from functools import wraps -def worker_identity(job_context) -> dict: - """Which machine and worker are running this job. worker_id says nothing about the VM.""" - return {"machine": socket.gethostname(), "worker_name": job_context.worker_name} - - def set_timeout(seconds): """ Decorator that enforces a timeout on an async function. diff --git a/backend/shredder/tests/mock_server.py b/backend/shredder/tests/mock_server.py new file mode 100644 index 0000000..8ec5948 --- /dev/null +++ b/backend/shredder/tests/mock_server.py @@ -0,0 +1,96 @@ +""" +Mock HTTP server for the integration tests. Run it in a background thread of the test +process so the tests can read the callbacks it receives directly from memory. + +Endpoints: +- GET /example.pdf Serves tests/static/example.pdf as the document to convert. +- POST /webhook Records Shredder's multipart callback, keyed by its job_id field. +""" + +import threading +from email import policy +from email.parser import BytesParser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +STATIC_DIR = Path(__file__).parent / "static" +DEFAULT_PORT = 1234 + + +def parse_multipart(content_type: str, body: bytes) -> tuple[dict, dict]: + """Split a multipart/form-data body into (fields, files).""" + message = BytesParser(policy=policy.HTTP).parsebytes( + f"Content-Type: {content_type}\r\n\r\n".encode() + body + ) + fields, files = {}, {} + for part in message.iter_parts(): + name = part.get_param("name", header="content-disposition") + payload = part.get_payload(decode=True) or b"" + filename = part.get_filename() + if filename is None: + fields[name] = payload.decode() + else: + files[name] = {"filename": filename, "content": payload} + return fields, files + + +class MockServerHandler(BaseHTTPRequestHandler): + server: "MockServer" + + def do_GET(self): + if self.path == "/example.pdf": + self._send(200, (STATIC_DIR / "example.pdf").read_bytes(), "application/pdf") + else: + self._send(404, b"not found", "text/plain") + + def do_POST(self): + if self.path != "/webhook": + self._send(404, b"not found", "text/plain") + return + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + fields, files = parse_multipart(self.headers.get("Content-Type", ""), body) + self.server.record_callback( + fields.get("job_id", ""), + { + "authorization": self.headers.get("Authorization"), + "fields": fields, + "files": files, + }, + ) + self._send(200, b"ok", "text/plain") + + def _send(self, status: int, body: bytes, content_type: str): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +class MockServer(ThreadingHTTPServer): + """ + Each request is handled on its own thread, so callbacks are shared through a + lock-protected dict, with a threading.Event per job_id to wake up waiting tests. + """ + + def __init__(self, port: int = DEFAULT_PORT, host: str = "0.0.0.0"): # noqa: S104 + super().__init__((host, port), MockServerHandler) + self._lock = threading.Lock() + self._callbacks: dict[str, dict] = {} + self._events: dict[str, threading.Event] = {} + + def _event_for(self, job_id: str) -> threading.Event: + with self._lock: + return self._events.setdefault(job_id, threading.Event()) + + def record_callback(self, job_id: str, record: dict) -> None: + with self._lock: + self._callbacks[job_id] = record + self._event_for(job_id).set() + + def wait_for_callback(self, job_id: str, timeout: float) -> dict | None: + """Block until the callback for job_id arrives; None if it doesn't within timeout.""" + if not self._event_for(job_id).wait(timeout): + return None + with self._lock: + return self._callbacks[job_id] diff --git a/backend/shredder/tests/static/example.pdf b/backend/shredder/tests/static/example.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6cc662c47e67f8d0aee957fafa59f70a25281300 GIT binary patch literal 74656 zcmce81z6Nw(64|rNJvO8-LbHg2q@iMlG5D`N=TPTDJ4itgMfg5fOIISfS^c+(kLNu z|19{qDpz0cbDvLrHfGPxnVB>3JLk+wt0X4D3TA_#(GGoDnnDAEI6(GBR%q9*v8!3S z*qDMCR1Iw%fM+p#V^>>KI~S0dy%Pxd4oJ<^*~QY%oDq#()XCJ)1)ws>+k08s*ch^N zvvGiQ88j{JOzhpAK?-UhFb5ll00B|*_AxdDfzjAS0kT2WyeJ^Ke zXN|@#0fGSEw|6oDWY^&UzQdu1#;#&&>;lr^gmAL)@j&?bL0nvH{CxZ;FTmVv5MF+M zJ`e=L#?8ac!NCa(zX3W~snbb}T>xEySwQUSD$-~t4+)#c+%#A+fpgw7PIa8`(>P4-9RKXBFh5j1zQ&97A-A)g=z=9BO~B{OSE;Xd zd?${+(r=Bg?2wE`<~-#3Qh{}_y)qwlaQKD3;`Z(1+umE3llzWeWN^hai%GT%&|@5X zR&=olHy)Y1+u7-}Hdqzhv1v~E90#`3}hH)O15A!R5u!ofWcoR%z zEBLooc%GCk(OtEv3&WM<43kVM8;rj&F>vW=JJFgRCyvF6Ni&OZ?8Q%jAk`sx<0*~F z)%KGIw~u>}XL{@0u_#und819Rd*e9Rxw=Ul=#aLySH8aG$9Rpy-|?DXVqtNBU*@D9!xcZ>InwsP zd%vbWe+QKVnMBns199|QvbcLrYXcVnoi3(U0&Dj|0RwaM^E)fY4yi$Ho^Nhw#-S?) zV5V7@MPN(_h<|#9l!54g$+Y7~NItQryv${>qUmz4p?Dm;zsV4KRT+Xc(3Vakm@hpa z7geJ6p;!R^*#vu0eZ{@hX#e5Q`Z`j9W|P{xhVPy_pcC{v&ZbFUl10!-zkI#37;f`r zkIvnAQilb^Ap$nm0g+81qtBRjM7TChA1{9)$RsShX8ZVC>w|D{Wa5!lL5n1T+~*W5 zZE|T_h&$<8{9iC;)RsEJtmu<)o`uz>M_nW!^|}>cCw@zjd6Z?QTKd)1d`Y)vMxio| zhz=Zefze)ig+zqV5R)+8tM-MhXb!{!)j0{V1>{)N$rOs8RgWo)k7k^Y+st(MHVtMf z;2TP?T~OXR3+HkPXMUM|`S_bQQ3QK^fy>f!1CqoJ{RACdmqxVNmj*kB2u7FRzKO(I zDDQIim9`HoaNCtgFdhd?hERsXySYrWQD*_pKzmK;CoQ_eK2^((5%*D>8aX z7h9gvshNroRH2phJeD+7N|yh)wY+dh%GgBY^eK~wylz^&naqhXcL816NGnO`i8j0C z7av`*XWJ!jdo@-NUvBzLJb$92>ZPT#xKs{)?c3k3%oG3AbZbST>%k*WhWNm=1{`9f zOSc#VK8B@T3K)qEvR9NvMum%C zfUy~DWEkg^5p*#oP=YkGMdUJf_aZ|Wr5hib#Z8gcUa3-^-&s z-3znPRV9yo&r;Aza`j#K4KUE-vg?#0@ zNq8TvhXChVDCwIfpXX7pjChFK=yHsBEc_YqD-Nj~)LCiKuQ-(#YTwV)dL^BboOye{ z5<~w{Y-S{mr0YVCmWTJsJYEDreT^rkRJgbS2a|(klts9#*odF3sA8B*wqzVh99I*$5U=>z_@_QXZ9YqtG< zg7*E_8BhgjgOEPAT*3^byGe`;!Cof79POAkP*cFy-Xrb5{)96Wj~K-^0!d^%%U?;{ zP{~5cfTU z!%@h^?pfX9CPoqOy(CeQVZ?(4zjOgJkf~N);-+7lm2hG>vIS_El^8|dW9XyVwd)8O zb_0HbB{*$xm@)w}s1q5@AhDdAs**X>tNph)lyW?rgObr+8CwnZ!x2AW$PTcWBj`nd zJXVF=axk}O(t&`g#SxsmA)rP{giT3{>psy)7ri0(+qTFtQNCJSFOx*LOAP^yX8HHy z5o6!8=uh$9bmbA0uWCx`*Q-j~eIF2>CiO zAma7F)zyoKr`Y?Q)t1*?cggLr{=+wUkzZ}nbp?@@KpM3{n-|b`Nzp_5%0Aj$c5xDd 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

D> zTx$j!Y`F1Y^*Z;r&sr;$kI}ozmdQ;Wz7$iofzJ#qP!fF6p@YkbgC#}|Xi45=ZmmAK z81o{Jx33;fU8^>tNVdu=5bmLA@1;V55J1|uHu{rwM_w4SN>9OyfRYhTY z91xLGo36I%b0}hD=P+vcX?$%zRneveL7?jC8c$Wm>w8=S9AW*Nimqs3p%OcZOhOrx z+8I`QQe9o}kt5ppdDJ&?foKlRDTlWR7V%Q7#V<+H`?AG*uH7JtLLX(mpep=Lv3~iKcd}-QlW_rlE1HU92d5vG#;B90db>=a9ryFZXP)=KX{2SM-$gyNYWwnX zQ(BHgZ1?oX>|h?ty&b1*C9{Cbh_mcm~h7-CLfLhwg6*W_dGJONN~E?^1u^Oc2rq^eg=10U*ccM{j+ zx0m%Se#z0ObEFwQwDixXYd^anav^6CpSA%3z}A6M{v8&S{Wn-p1m$7sU~B$2 zGYA8a7-@HNOD7KiwStODz`+m*iP)cl{)i+%Ab@3qh{FJq$HNkc%m@NdtUtIP2>efK z5MT)<0O>!eK@pI|f3-tK#@UGV9t<8H~E0(f>anwtOv>Hy7 z^7E8a1)Rug2Q_b#c3U~y%5W@cvAyyXpQ`V!(<_Y;yTTh0QyZZJ) zCPY{PJ~dgo*s!RHj#e_TCcWd4`V-u=^HKG;Gr?9EDI_k-m$E0W7RoZ&-d4`jBdBsfEd@Z`0p@ zm|$}?!4efBrSVPVhUH|_`@FQ(eXFn9y!@>%Bc=NZqPON4uAz)QfYLoL z69@nC8h?(z(t#zA;(*@&kU`;qvpOe(O915bIhnXP7y`_Wf8-Sx2OQyf85jbz_MhoM z5I8^tpOZ;|;pb)IV3;_b9$*MS&i*s6I2Zu}%=zc|OE(}d@_fDG5OFBra?hnh{$aNN zDT7Er@!A3bf$?;M!tiYmfx*w)SsVg^0W9yiI=~RTwm_hOb32y~DvsA5;t&MjZ_cHI zz+w1pMgl1NxpYvd1YW&RFut9k0Pc;K7xHJx`g8nkXD9-U-)1;|f4~6iP_Zwh(ytcqWFuXe8 z;t+fp7=OILC4hh)=k$OBGZRk+f#S;$e^$*u$KQPfhr{q>z_tc2F9IZimk!|l@%jib z0iInDV7##b0zvTGf&}A_Nd!y+Z=52a_&x`L1mUNHBk^Rwu)>##BhJf!eG~$3Y#^ck zp*#qFn~{h=K^*>RXCxf2kAO`co__&QZ^U_-`SL*G2z(h3`2sH;6oDs`0D?2%rGq2!>IJIBlYx*RJedRtZ(ahk9N#Vy z2t0oZ5bmJ!{tsAZ@a+c-Nj!a_z(6>s2e5>K&f6K-f#LZVums?s&*ue;|B0aSkA8!~ z;COb10?j*LFBA^LUy~8Q;5b(v&^p+8JpiwZX9GB3=YQ)X4>wa=2TL~+Nl7tnTVKn6 x?JdOaIXeSil7Q0we5XJ;Svhk6n*VDf;_hMU=JBs>2pn+1FcKafc?|`U{{y#i5cmK9 literal 0 HcmV?d00001 diff --git a/backend/shredder/tests/test_functional.py b/backend/shredder/tests/test_functional.py new file mode 100644 index 0000000..efb1998 --- /dev/null +++ b/backend/shredder/tests/test_functional.py @@ -0,0 +1,181 @@ +"""Functional tests for the shredder application. No database, network, or worker required.""" + +import asyncio +import io +import time +import zipfile +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from shredder.tasks import ConversionError, convert_pdf_to_html, zip_directory +from shredder.utils import set_timeout +from tests.mock_server import parse_multipart + +TIMEOUT_SECONDS = 0.1 +MAX_EXPECTED_SECONDS = 1 +EXAMPLE_PDF = Path(__file__).parent / "static" / "example.pdf" +PDF2HTMLEX = Path("/usr/local/bin/pdf2htmlEX") +DOC_URL = "http://docs.example/example.pdf" +CALLBACK_URL = "http://callback.example/webhook" +CALLBACK_AUTH = "Bearer test-token" + + +@set_timeout(TIMEOUT_SECONDS) +async def slow_task(): + await asyncio.sleep(2) + + +@set_timeout(1) +async def fast_task(): + return "done" + + +async def test_functional_set_timeout(): + """A task that exceeds its timeout is cancelled with a TimeoutError.""" + start = time.monotonic() + with pytest.raises(TimeoutError): + await slow_task() + elapsed = time.monotonic() - start + assert TIMEOUT_SECONDS <= elapsed < MAX_EXPECTED_SECONDS + + +async def test_functional_set_timeout_returns_result(): + assert await fast_task() == "done" + + +def _job_context(job_id=42): + return SimpleNamespace(job=SimpleNamespace(id=job_id)) + + +def _fake_pdf2htmlex(returncode=0): + """Stand-in for pdf2htmlEX: checks its input and writes HTML into --dest-dir.""" + calls = [] + + async def fake_exec(*command, **_kwargs): + calls.append(command) + input_pdf = Path(command[-1]) + assert input_pdf.read_bytes() == EXAMPLE_PDF.read_bytes() + if returncode == 0: + dest_dir = Path(command[command.index("--dest-dir") + 1]) + (dest_dir / f"{input_pdf.stem}.html").write_text("converted") + (dest_dir / "pages.css").write_text("body {}") + process = MagicMock(returncode=returncode) + + async def communicate(): + return b"", b"" if returncode == 0 else b"conversion failed" + + process.communicate = communicate + return process + + return fake_exec, calls + + +@contextmanager +def mock_http(): + """Route the task's httpx client to an in-memory transport and record its requests.""" + calls = {"get": [], "post": []} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET": + calls["get"].append(str(request.url)) + return httpx.Response(200, content=EXAMPLE_PDF.read_bytes()) + fields, files = parse_multipart(request.headers["content-type"], request.content) + calls["post"].append( + { + "url": str(request.url), + "authorization": request.headers.get("authorization"), + "fields": fields, + "files": files, + } + ) + return httpx.Response(200) + + real_client = httpx.AsyncClient + + def client_factory(*args, **kwargs): + return real_client(*args, transport=httpx.MockTransport(handler), **kwargs) + + with patch("shredder.tasks.httpx.AsyncClient", side_effect=client_factory): + yield calls + + +async def _run_conversion(**overrides): + kwargs = { + "doc_path": DOC_URL, + "call_back_url": CALLBACK_URL, + "call_back_auth": CALLBACK_AUTH, + "doc_provider": "url", + "metadata": {"reference": "abc-123"}, + **overrides, + } + return await convert_pdf_to_html(_job_context(), **kwargs) + + +async def test_functional_convert_pdf_to_html(): + """The task downloads the PDF, converts it, and POSTs a zip to the callback URL.""" + fake_exec, exec_calls = _fake_pdf2htmlex() + with ( + mock_http() as http, + patch("shredder.tasks.asyncio.create_subprocess_exec", side_effect=fake_exec), + patch("shredder.tasks.asyncio.to_thread", wraps=asyncio.to_thread) as to_thread, + ): + await _run_conversion() + + assert http["get"] == [DOC_URL] + to_thread.assert_called_once() + assert to_thread.call_args.args[0] is zip_directory + assert len(exec_calls) == 1 + assert exec_calls[0][0] == str(PDF2HTMLEX) + + assert len(http["post"]) == 1 + posted = http["post"][0] + assert posted["url"] == CALLBACK_URL + assert posted["authorization"] == CALLBACK_AUTH + assert posted["fields"] == { + "job_id": "42", + "doc_path": DOC_URL, + "doc_provider": "url", + "reference": "abc-123", + } + upload = posted["files"]["file"] + assert upload["filename"] == "42.zip" + assert zipfile.is_zipfile(io.BytesIO(upload["content"])) + with zipfile.ZipFile(io.BytesIO(upload["content"])) as archive: + names = archive.namelist() + html_files = [n for n in names if n.endswith(".html")] + assert len(html_files) == 1 + assert archive.read(html_files[0]) == b"converted" + assert "pages.css" in names + assert not [n for n in names if n.endswith(".zip")], "zip must not contain itself" + + +async def test_functional_convert_pdf_to_html_without_auth(): + """No Authorization header is sent when call_back_auth is not supplied.""" + fake_exec, _ = _fake_pdf2htmlex() + with ( + mock_http() as http, + patch("shredder.tasks.asyncio.create_subprocess_exec", side_effect=fake_exec), + ): + await _run_conversion(call_back_auth=None, metadata=None) + + posted = http["post"][0] + assert posted["authorization"] is None + assert set(posted["fields"]) == {"job_id", "doc_path", "doc_provider"} + + +async def test_functional_convert_pdf_to_html_conversion_error(): + """A failing pdf2htmlEX run raises ConversionError and nothing is POSTed.""" + fake_exec, _ = _fake_pdf2htmlex(returncode=1) + with ( + mock_http() as http, + patch("shredder.tasks.asyncio.create_subprocess_exec", side_effect=fake_exec), + pytest.raises(ConversionError, match="conversion failed"), + ): + await _run_conversion() + + assert http["post"] == [] diff --git a/backend/shredder/tests/test_integration.py b/backend/shredder/tests/test_integration.py new file mode 100644 index 0000000..a13298f --- /dev/null +++ b/backend/shredder/tests/test_integration.py @@ -0,0 +1,130 @@ +""" +Integration tests for the shredder application. + +Requires the shredder worker container and its Procrastinate database to be running. +`make test-integration` starts the container. The test starts the mock server in a background +thread and reads the callbacks it receives directly from memory. + +Optional environment overrides: +- MOCK_SERVER_PORT: mock server port (default 1234). +- MOCK_SERVER_CALLBACK_HOST: hostname the worker container uses to reach the mock server + (default host.docker.internal). +- TEST_PROCRASTINATE_DB_HOST / TEST_PROCRASTINATE_DB_PORT: database address as seen from the + machine running the tests (default 127.0.0.1 / PROCRASTINATE_DB_PORT). +- MOCK_SERVER_STARTUP_DELAY: seconds to wait after starting the mock server, because Docker + Desktop takes a moment to forward a newly opened host port into containers (default 2). +- INTEGRATION_TIMEOUT: seconds to wait for the callback (default 120). +""" + +import asyncio +import io +import os +import threading +import time +import zipfile + +import procrastinate +import pytest +from procrastinate.jobs import Status +from psycopg.conninfo import make_conninfo + +from tests.mock_server import MockServer + +MOCK_SERVER_PORT = int(os.getenv("MOCK_SERVER_PORT", "1234")) +CALLBACK_HOST = os.getenv("MOCK_SERVER_CALLBACK_HOST", "host.docker.internal") +TIMEOUT = float(os.getenv("INTEGRATION_TIMEOUT", "5")) +STARTUP_DELAY = float(os.getenv("MOCK_SERVER_STARTUP_DELAY", "2")) +WORKER_URL = f"http://{CALLBACK_HOST}:{MOCK_SERVER_PORT}" +TASK_NAME = "shredder.tasks.convert_pdf_to_html" +FAILED_STATUSES = {Status.FAILED, Status.CANCELLED, Status.ABORTED} + + +@pytest.fixture(scope="module") +def mock_server(): + """Start the mock server in a background thread.""" + server = MockServer(MOCK_SERVER_PORT) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + time.sleep(STARTUP_DELAY) + try: + yield server + finally: + server.shutdown() + server.server_close() + + +@pytest.fixture +async def procrastinate_app(): + """Add an actual job to the procrastinate worker queue for testing.""" + connector = procrastinate.PsycopgConnector( + conninfo=make_conninfo( + host=os.getenv("TEST_PROCRASTINATE_DB_HOST", "127.0.0.1"), + port=os.getenv("TEST_PROCRASTINATE_DB_PORT", os.environ["PROCRASTINATE_DB_PORT"]), + user=os.environ["PROCRASTINATE_DB_USER"], + password=os.environ["PROCRASTINATE_DB_PASSWORD"], + dbname=os.environ["PROCRASTINATE_DB_NAME"], + ) + ) + app = procrastinate.App(connector=connector) + async with app.open_async(): + yield app + + +async def _wait_for_callback( + app: procrastinate.App, + server: MockServer, + job_id: int, + *, + callback_auth: str, + expected_fields: dict, +) -> None: + """Wait for the job's callback to reach the mock server, then validate it.""" + received = None + deadline = time.monotonic() + TIMEOUT + while received is None and time.monotonic() < deadline: + # Wait in 1s steps on a worker thread so the event loop stays free and a failed + # job is reported straight away. + received = await asyncio.to_thread(server.wait_for_callback, str(job_id), 1) + if received is None: + status = await app.job_manager.get_job_status_async(job_id) + if status in FAILED_STATUSES: + pytest.fail(f"Job {job_id} ended with status {status.value}; see worker logs.") + if received is None: + pytest.fail(f"No callback received for job {job_id} within {TIMEOUT}s.") + + assert received["authorization"] == callback_auth + assert received["fields"] == {"job_id": str(job_id), **expected_fields} + upload = received["files"]["file"] + assert upload["filename"] == f"{job_id}.zip" + assert zipfile.is_zipfile(io.BytesIO(upload["content"])) + with zipfile.ZipFile(io.BytesIO(upload["content"])) as archive: + html_files = [n for n in archive.namelist() if n.endswith(".html")] + assert len(html_files) == 1 + html = archive.read(html_files[0]) + assert b"pdf2htmlEX" in html + assert b"page-container" in html + + +async def test_integration_conversion(procrastinate_app, mock_server): + """Queue a real job and check the worker POSTs the converted zip back to the mock server.""" + doc_path = f"{WORKER_URL}/example.pdf" + callback_auth = "Bearer integration-test-token" + metadata = {"reference": "integration-test"} + + job_id = await procrastinate_app.configure_task( + name=TASK_NAME, queue=os.getenv("PROCRASTINATE_QUEUE", "shredder_queue") + ).defer_async( + doc_path=doc_path, + call_back_url=f"{WORKER_URL}/webhook", + call_back_auth=callback_auth, + doc_provider="url", + metadata=metadata, + ) + + await _wait_for_callback( + procrastinate_app, + mock_server, + job_id, + callback_auth=callback_auth, + expected_fields={"doc_path": doc_path, "doc_provider": "url", **metadata}, + ) diff --git a/backend/uv.lock b/backend/uv.lock index be3a563..1788f27 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -5,6 +5,19 @@ required-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "asgiref" version = "3.13.0" @@ -33,58 +46,40 @@ wheels = [ ] [[package]] -name = "charset-normalizer" -version = "3.5.2" +name = "colorama" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, - { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, - { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, - { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, - { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, - { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, - { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, - { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, - { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, - { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, - { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, - { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, - { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, - { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, - { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, - { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, - { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, - { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] -name = "colorama" -version = "0.4.6" +name = "coverage" +version = "7.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/54/e6/1f01afbc9f4fa8ca5e1fa6fdf537a5c58b5765d1b4167ee284cddb9acc72/coverage-7.17.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:84538c040f4e6214f663459d89372ce4e6b2bbdd9d397ef0dca7b3ecd56b8eb2", size = 227080, upload-time = "2026-10-22T16:04:42.325Z" }, + { url = "https://files.pythonhosted.org/packages/92/7a/bdb7e6260c4a211389aeac9b89dc31662ebe122153ad0ecfd156897912df/coverage-7.17.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:6abfb6a4cf1cb1b67bc8fccdde177e2d2b781d0d34449e7078156547025140e5", size = 227152, upload-time = "2026-10-22T16:04:43.737Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/276e7d5b3cfb124871bdb544c27b004a8b4e270e6bf8e7414149c54b5e5b/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef65e96f0fc1a7c8b7644c7011d47a8228555e7888ac4d6246f715a7f3d7c398", size = 225397, upload-time = "2026-10-22T16:04:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7d/dac073db12f3c131598a6693253aded581e4d7d622a89864f11bfb0ca2f5/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:55939ea4c097fda2edabf80fac5e49bef15597b950a9f29fc56292a895245feb", size = 225705, upload-time = "2026-10-22T16:04:46.603Z" }, + { url = "https://files.pythonhosted.org/packages/00/45/877b0a9248e921080ae91d115c13192d17e308f5aee86bcc7f7bc2a54ed1/coverage-7.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bfb6f868af552c0ceb2d19220b8cbcc2be096ca26163ac30e682a13041433429", size = 225828, upload-time = "2026-10-22T16:04:48.065Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f5/2c48d47abec0e4590b302920334be5ee3854f96bbd22524feabb8d92b220/coverage-7.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8d823d66afd59566030539c49c7a6acaf3083996682be2dda760c63555842094", size = 226088, upload-time = "2026-10-22T16:04:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/ec0442f3f372c7fd742f49b3022edd392f79e37a0048e1fa333f56d01ec3/coverage-7.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50678474e714c684b82c108e63fbb8f1bcf1266d8293a49980f1d08526f093b5", size = 257257, upload-time = "2026-10-22T16:04:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/eb0927ba93809f76eee69fa1e47999825fc7dbbabc3e0e1a2744f2677acb/coverage-7.17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:045c1eeb19fb4eae949850a761102fffb3a5f638b97f977912f95843e54f6669", size = 260165, upload-time = "2026-10-22T16:04:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a2/8d4c6055efb32099b15c9cc33d007b8aabebc4832f372a3274de5ba0afc5/coverage-7.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb12891a773cbc2132349c77c690a045a882fe3f98b4bda50dfabb01893f9bd", size = 261098, upload-time = "2026-10-22T16:04:53.763Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/93412288688a7b03f152a734c8ef086e395108a0a6a746985d3967e220f2/coverage-7.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3067608b4429629f640c1b23d2f69b05d2fd45e00ebb6ee9e8fad220a6c11722", size = 263164, upload-time = "2026-10-22T16:04:55.386Z" }, + { url = "https://files.pythonhosted.org/packages/3c/20/7eb8d805c4f1a212245cef20181dfbf39535357985073d49730ee9cf08d4/coverage-7.17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1762e85cbbe3b5ff9c6dcbcc1b89d5c72320094292b7b9572bf882c85e428b7", size = 257287, upload-time = "2026-10-22T16:04:56.976Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/1df29630b4ed17e135c76390afcc6bd273c3dc5ba2a21a76813a600fe98c/coverage-7.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62bb77987d3f72a0aaa77c567ee7f9806bc2e748c8f46fafbe1fdb90dd148398", size = 259256, upload-time = "2026-10-22T16:04:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/a1/79/2365d1008d28aafe468a450a24c6c63363de794b5140223d67685cd0e205/coverage-7.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6cc31f27240d4e080437e3b8229d92f1d809c9a4f233607385373b73974e183e", size = 257178, upload-time = "2026-10-22T16:05:00.103Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9f/f002952d295d772688dffce0d991e3822795b1e3aad158073712a5a5e86e/coverage-7.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61cde5222f1933d476647c5a25ed1a8678b7703098cb5a15f4a0aa39967d8c28", size = 261275, upload-time = "2026-10-22T16:05:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/f9e15fbb426be97cd679ee5d359151e046f103bcd76b5092c318c26ed596/coverage-7.17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11f5f7fb2d97a0fe865f5e7bad4eb0619b957637f676bc088d13844adf26ba95", size = 257029, upload-time = "2026-10-22T16:05:02.8Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4a/3c398f38694387334ff4c9f71afc8e15b4fa7db7bc0bea4d5b6032eb0df0/coverage-7.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e09612b1b82c9df1d2b6c85dee88d5aad3e73d77a8ec0b16ac6a7322cbdf5d9", size = 259109, upload-time = "2026-10-22T16:05:04.241Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a9/908a3cf1a2335aec1c788682a06c2f742a62a4f987033f080de1c5fd1997/coverage-7.17.0-cp313-cp313-win32.whl", hash = "sha256:bd3e9eb493631ff1ca34bf5efac25b5c66724f837bf6182c4fc771c55946423d", size = 227691, upload-time = "2026-10-22T16:05:05.636Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/8cdd7bfd8a63b3e6b133fc395ecfc7b1cb6f561926e7e2ad050de85b83e7/coverage-7.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1fc9a7cea899c51d8a53e66c2f082a3d9e461ece32418d3ef0a0b90c32bfd3d", size = 228418, upload-time = "2026-10-22T16:05:07.057Z" }, + { url = "https://files.pythonhosted.org/packages/09/cf/0b3fc57f735cd3831ca13d0be5312a5477d5934066683c5d34018cab7541/coverage-7.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:e65a1e61a68493fbfbe44d8bec71b3231d81b9917f62d3dafe0e105072147e83", size = 228142, upload-time = "2026-10-22T16:05:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" }, ] [[package]] @@ -99,6 +94,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.20" @@ -158,28 +190,30 @@ name = "propylon-shredder" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "httpx" }, { name = "procrastinate" }, { name = "psycopg", extra = ["pool"] }, - { name = "requests" }, { name = "sqlalchemy" }, ] [package.dev-dependencies] dev = [ + { name = "coverage" }, { name = "pytest" }, { name = "pytest-asyncio" }, ] [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "procrastinate", specifier = ">=3.8.1" }, { name = "psycopg", extras = ["pool"] }, - { name = "requests" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, ] [package.metadata.requires-dev] dev = [ + { name = "coverage" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, ] @@ -224,7 +258,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.1.1" +version = "9.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -233,21 +267,21 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.4.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, + { url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" }, ] [[package]] @@ -263,21 +297,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, ] -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -320,18 +339,9 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, -] - -[[package]] -name = "urllib3" -version = "2.8.0" +version = "2026.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" }, ] From 171eafd35fcaecea3e69a1e374f1434475bcaa36 Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Wed, 28 Oct 2026 16:23:42 +0000 Subject: [PATCH 5/8] linting --- backend/pyproject.toml | 39 ++------------------------ backend/shredder/src/shredder/tasks.py | 26 ++++++++++++----- backend/shredder/tests/mock_server.py | 2 +- 3 files changed, 23 insertions(+), 44 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1c94287..5a50661 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -83,37 +83,9 @@ lint.select = [ # Ignore the same rules as current flake8 config lint.ignore = [ - # "B" - flake8-bugbear - # "B009", # Avoid calling getattr with a constant attribute name - # "B018", # Ignore useless expressions that have no effect - # "B904", # Don't require 'raise ... from err' inside except blocks - - # C - flake8-comprehensions and others - # "C403", # Ignore list comprehensions that could be set comprehensions - - # DJ - Django related issues to ignore - # "DJ001", # Checks nullable string-based fields (like CharField and TextField) in Django models. - - # E - pycodestyle errors - # "E203", # Ignore whitespace before punctuation (conflicts with formatter) - # "E501", # Ignore line length (handled by formatter) - - # RUF - Ruff-specific rules - # "RUF012", # Mutable class attributes - conflicts with Django patterns (Admin, Models, migrations) - # "RUF001", # Ambiguous unicode characters in strings - intentional for legal text, quote conversion, and character mappings - - # UP - pyupgrade - # "UP035", # Ignore deprecated imports from collections.abc - - # "ARG001", - # "ARG002", - # "ERA001", - # "PGH003", - # "PGH004", - # "PLR2004", - # "TRY003", - # "TRY300", - # "TRY401", + "PLR0917", + "PLR0913", + "TRY003", ] lint.fixable = ["ALL"] @@ -131,11 +103,6 @@ known-first-party = ["shredder", "tests"] [tool.ruff.lint.isort.sections] -[tool.ty.rules] -# possibly-missing-attribute = "ignore" -# invalid-argument-type ="ignore" -# unresolved-attribute = "ignore" - [tool.ty.src] include = ["shredder/src", "shredder/tests"] exclude = [] diff --git a/backend/shredder/src/shredder/tasks.py b/backend/shredder/src/shredder/tasks.py index 303ddff..f934d1e 100644 --- a/backend/shredder/src/shredder/tasks.py +++ b/backend/shredder/src/shredder/tasks.py @@ -1,11 +1,9 @@ import asyncio -import io import logging import os import tempfile import zipfile from pathlib import Path -from typing import Any import httpx @@ -44,7 +42,14 @@ def zip_directory(src_dir: str, zip_path: str) -> None: zip_ff.write(Path(root) / file, arcname=file) -async def handle_conversion(job_id, doc_path, call_back_url, call_back_auth, doc_provider, metadata): +async def handle_conversion( + job_id, + doc_path, + call_back_url, + call_back_auth, + doc_provider, + metadata +): """ Handles the conversion of a PDF document to HTML using pdf2htmlEX. - Downloads the document from the specified provider @@ -71,7 +76,7 @@ async def handle_conversion(job_id, doc_path, call_back_url, call_back_auth, doc # and a temp directory for the output HTML files with ( tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tff, # The incoming PDF file - tempfile.TemporaryDirectory(delete=True) as tdir, # Directory for conversion output files + tempfile.TemporaryDirectory(delete=True) as tdir, # Directory for conversion output tempfile.TemporaryDirectory(delete=True) as zip_dir, # Directory for the zipped output ): # Download the document from the specified provider and write it to the temp file @@ -124,7 +129,8 @@ async def handle_conversion(job_id, doc_path, call_back_url, call_back_auth, doc ) zip_ff_len = zip_ff.seek(0) logger.info( - "Posted result data: %s with file len %d to callback URL: %s, response code: %d", + "Posted result data: %s with file len %d " \ + "to callback URL: %s, response code: %d", str(data), int(zip_ff_len), str(call_back_url), @@ -134,7 +140,12 @@ async def handle_conversion(job_id, doc_path, call_back_url, call_back_auth, doc @shredder.app.task(queue=settings.PROCRASTINATE_QUEUE, pass_context=True) async def convert_pdf_to_html( - job_context, doc_path: str, call_back_url: str, call_back_auth: str | None = None, doc_provider: str = "url", metadata: dict | None = None + job_context, + doc_path: str, + call_back_url: str, + call_back_auth: str | None = None, + doc_provider: str = "url", + metadata: dict | None = None ): """ A wrapper task for the conversion function. Allows setting a job time-out. @@ -143,7 +154,8 @@ async def convert_pdf_to_html( - job_context: Automatically supplied by procrastinate when triggered. - doc_path: The URL pointing to the file to be converted. - call_back_url: The URL to post the conversion result (zip file) to. - - call_back_auth: Optional `Bearer TOKEN` string for authorization header for the callback endpoint. + - call_back_auth: Optional `Bearer TOKEN` string + for authorization header for the callback endpoint. - doc_provider: The provider to fetch the document from. Only "url" is currently supported. - metadata: Optional passed-through metadata dict/JSON to include in the callback. """ diff --git a/backend/shredder/tests/mock_server.py b/backend/shredder/tests/mock_server.py index 8ec5948..6ddd45a 100644 --- a/backend/shredder/tests/mock_server.py +++ b/backend/shredder/tests/mock_server.py @@ -25,7 +25,7 @@ def parse_multipart(content_type: str, body: bytes) -> tuple[dict, dict]: fields, files = {}, {} for part in message.iter_parts(): name = part.get_param("name", header="content-disposition") - payload = part.get_payload(decode=True) or b"" + payload = bytes(part.get_payload(decode=True)) or b"" filename = part.get_filename() if filename is None: fields[name] = payload.decode() From 1b9c92edc41633c1c259c0b05ba0b6b798140432 Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Mon, 2 Nov 2026 09:45:44 +0000 Subject: [PATCH 6/8] code quality --- backend/pyproject.toml | 2 + backend/shredder/src/shredder/tasks.py | 2 +- backend/uv.lock | 54 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5a50661..29db1e3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,8 @@ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", "coverage", + "ruff==0.16.3", + "ty==0.0.72", ] [build-system] diff --git a/backend/shredder/src/shredder/tasks.py b/backend/shredder/src/shredder/tasks.py index f934d1e..a72c65e 100644 --- a/backend/shredder/src/shredder/tasks.py +++ b/backend/shredder/src/shredder/tasks.py @@ -127,7 +127,7 @@ async def handle_conversion( files={"file": (Path(zip_path).name, zip_ff, "application/octet-stream")}, headers=headers, ) - zip_ff_len = zip_ff.seek(0) + zip_ff_len = zip_ff.seek(0, os.SEEK_END) logger.info( "Posted result data: %s with file len %d " \ "to callback URL: %s, response code: %d", diff --git a/backend/uv.lock b/backend/uv.lock index 1788f27..2c2cf4b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -201,6 +201,8 @@ dev = [ { name = "coverage" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -216,6 +218,8 @@ dev = [ { name = "coverage" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "ruff", specifier = "==0.16.3" }, + { name = "ty", specifier = "==0.0.72" }, ] [[package]] @@ -297,6 +301,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, ] +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -328,6 +357,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/69/c75534e336b06b3fd3f76de73d89736510800e60c77c58d4e4ce18389018/sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe", size = 2063055, upload-time = "2026-10-21T18:42:10.234Z" }, ] +[[package]] +name = "ty" +version = "0.0.72" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, + { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, + { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 11c60674a07bc93d825e79f75987a52d32e411f0 Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Mon, 2 Nov 2026 09:53:08 +0000 Subject: [PATCH 7/8] rm cov file --- backend/.gitignore | 1 + backend/.pytest_coverage | Bin 53248 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 backend/.pytest_coverage diff --git a/backend/.gitignore b/backend/.gitignore index cccd8fa..b954175 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -42,6 +42,7 @@ pip-delete-this-directory.txt htmlcov/ .tox/ .nox/ +.pytest_coverage .coverage .coverage.* .cache diff --git a/backend/.pytest_coverage b/backend/.pytest_coverage deleted file mode 100644 index c513a2dd34cf6a711dd6c71ffde2faab208b3b63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI4+iM(E9LHyNW_Naz-JEC)ON^nXpeCWY7!>N|p^3KGV!c$Wh!1XOcTcioc4yL= z*)|bHRs}BzeH1|yd{O@ZQIr0nYyGO~U?p4_Rxl7r2b}sv4_w(6nGk14e##m;8G7vxj1V8`;E*Juh16dm20^!sWA8RdCBLTP4%C z>^bgNL|nS=iL#utD2-+68O;h&GlAw~~ zmZkPZ*R)F&;f--~#ln(9dM^5*wXRc}^c`Nc?7&RhvwX|3xmXmXx-ZIW8aO7Q#ev|p zT0^o9NrMiBHKTL2&}mj)qh?e*sys`|*-nhLSy7&vDhE2-^;(gTKTxkN$i|@>;fZ~x zQ_W6weJ3*ZtlMUdwrtwl)|@eN*`i^sx@{eXqO_6UwoQHFaNsBsPoZe}-by6X<}@qG9b8A| zclI0EOWK`8_M&Q)JF`KTk+pf)IONT(eP%L-VY;%TaVIKBnr^ALNo7d~UaUIBc3%rT z#uL;*Xe@|}&DQOb>?)qLq2wG9t~n&byqj6SEm|oqD2aha!0pz@PiK)*(VzWi!!D$A6n%D0H6@gruHMw128a;eMG7#z+3Tox>wRM;#! zbwBW5c^J5-oO_!3h60+$F?%XC8WgAQeTUMUh&~w9CY}S;#zLc9Iby}~Cl-i_lv*>(VRx5}(jxI_MVuinoQ-yyzVNM@ z;Ic@dOyF2YO^=scL7CB98Yo7@Dyy{$#q6b=cnl4#86DRC&39xI7pCM^u9DO8Qm(O0 zHS#-lsEvBmgTuk8;L(*p&5Ui04ijyjwJNjeWVh2OO_xZm&Lx4jFqDVk8RUosyz! znp2uDc*3RiBMI7@B0>G{pTuZ z&Gmn3!;<3le{#Rx7~GIfE$e@M!@^n?+uC}Bbz1)?cI%B?W?df9q7vT!w>8}vX9WQe z009sH0T2KI5C8!X009sHf&YkrrfNz~-v3wGp9*~tKmY_l00ck)1V8`;KmY_l00ck) z1TH`VnwHB1&;PRr74`@FjD5h~VaM4^>=-2=fB*=900@8p2!H?xfB*=900@8p2y6m@ zzO=4pM$!r8_v6c1MN>Xk6{YtggAy+{5)XZ}JUgRIzumh#J&>ZLYf{UvJ@dx)7nBr# z|Eq6?etP|fW7>2+ousUblUFV#4V_}W`nM-C8bvPA?)&S&&}9jVUY|Ji`f2aB7oR)j zymxBXYkxfZ=83~(~_67TteMkuiAOHd&00JNY z0w4eaAOHd&00JNY0_PzC`7?oY From c55f7f57b7e677c666ef9773e2706e7b88c06058 Mon Sep 17 00:00:00 2001 From: Paul Higgins Date: Mon, 2 Nov 2026 10:01:53 +0000 Subject: [PATCH 8/8] fix gitaction workflow --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebc0721..115af5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,10 @@ name: Release Backend on: workflow_call: + inputs: + environment: + required: true + type: string jobs: build-and-push: