From 06bfe5838a126178c991e940163e00d753588905 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 16:33:50 -0300 Subject: [PATCH] feat: add runnable application skeleton --- .dockerignore | 18 ++ config/__init__.py | 0 config/asgi.py | 4 + config/wsgi.py | 4 + core/__init__.py | 0 docker/postgres/init.sh | 13 ++ docker/postgres/test-init.sh | 6 + .../plans/2026-09-06-application-skeleton.md | 39 ++++ .../2026-09-06-application-skeleton-design.md | 103 ++++++++++ manage.py | 7 + pyproject.toml | 14 ++ tests/check_permissions.py | 76 ++++++++ tests/test_configuration.py | 13 ++ tests/test_health.py | 20 ++ uv.lock | 179 ++++++++++++++++++ 15 files changed, 496 insertions(+) create mode 100644 .dockerignore create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 docker/postgres/init.sh create mode 100644 docker/postgres/test-init.sh create mode 100644 docs/superpowers/plans/2026-09-06-application-skeleton.md create mode 100644 docs/superpowers/specs/2026-09-06-application-skeleton-design.md create mode 100644 manage.py create mode 100644 pyproject.toml create mode 100644 tests/check_permissions.py create mode 100644 tests/test_configuration.py create mode 100644 tests/test_health.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b8d1fa0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +* +!Dockerfile +!pyproject.toml +!uv.lock +!manage.py +!config/ +!config/** +!core/ +!core/** +!tests/ +!tests/** +**/__pycache__/ +**/*.pyc +.env* +!finance/ +!finance/** +!ingestion/ +!ingestion/** diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..8acb06f --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,4 @@ +import os +from django.core.asgi import get_asgi_application +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_asgi_application() diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..72126a5 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,4 @@ +import os +from django.core.wsgi import get_wsgi_application +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/postgres/init.sh b/docker/postgres/init.sh new file mode 100644 index 0000000..3d6094d --- /dev/null +++ b/docker/postgres/init.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" --set=migration_password="$MIGRATION_PASSWORD" --set=runtime_password="$RUNTIME_PASSWORD" <<'SQL' +CREATE ROLE gizmoduck_migrate LOGIN PASSWORD :'migration_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE ROLE gizmoduck_app LOGIN PASSWORD :'runtime_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +REVOKE ALL ON DATABASE gizmoduck FROM PUBLIC; +GRANT CONNECT ON DATABASE gizmoduck TO gizmoduck_migrate, gizmoduck_app; +REVOKE ALL ON SCHEMA public FROM PUBLIC; +ALTER SCHEMA public OWNER TO gizmoduck_migrate; +GRANT USAGE ON SCHEMA public TO gizmoduck_app; +ALTER DEFAULT PRIVILEGES FOR ROLE gizmoduck_migrate IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO gizmoduck_app; +ALTER DEFAULT PRIVILEGES FOR ROLE gizmoduck_migrate IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO gizmoduck_app; +SQL diff --git a/docker/postgres/test-init.sh b/docker/postgres/test-init.sh new file mode 100644 index 0000000..4d0ef6d --- /dev/null +++ b/docker/postgres/test-init.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" --set=test_password="$POSTGRES_PASSWORD" <<'SQL' +CREATE ROLE gizmoduck_test LOGIN PASSWORD :'test_password' NOSUPERUSER CREATEDB NOCREATEROLE; +ALTER DATABASE gizmoduck_tests OWNER TO gizmoduck_test; +SQL diff --git a/docs/superpowers/plans/2026-09-06-application-skeleton.md b/docs/superpowers/plans/2026-09-06-application-skeleton.md new file mode 100644 index 0000000..ae4fae9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-application-skeleton.md @@ -0,0 +1,39 @@ +# Application Skeleton Implementation Plan + +**Goal:** Execute the approved application skeleton design. +**Spec:** [Design](../specs/2026-09-06-application-skeleton-design.md). +**Architecture:** Compose services separate runtime, migration, and test access. +**Stack:** Python 3.13, Django 5.2, PostgreSQL 17, uv and pytest. + +- [x] Create locked dependency configuration, Docker image, and minimal Django configuration. Add health success/failure tests and observe failure before implementing the endpoint. +- [x] Provision development database roles using initialization scripts; run migrations explicitly. Run pytest on an isolated PostgreSQL test service. +- [x] Add runtime permission checks using rolled-back synthetic rows and denied DDL/role escalation. Verify the real database grants. +- [x] Document setup and existing-volume behavior, verify HTTP startup, migration drift, system checks, and secret exclusions. Review all changes before completion. + +Files: config/, core/, tests/, docker/postgres/, manage.py, pyproject.toml, +uv.lock, Dockerfile, .dockerignore, compose.yaml, .env.example and README.md. +No financial models or real data. Application startup never runs migrations. +Verification commands: docker compose config --quiet; docker compose build; +docker compose run --rm manage migrate; docker compose run --rm test; +docker compose run --rm permissions; docker compose run --rm manage check; +docker compose run --rm manage makemigrations --check --dry-run; +docker compose run --rm manage migrate --check; request localhost:8000/health/. + +## Verification results (2026-09-06) + +- Locked Docker build succeeded; Django 5.2.17 installed under Python 3.13. +- Three pytest tests passed against isolated PostgreSQL, including HTTP success, + generic database failure, and missing-secret validation. +- Fourteen built-in migrations applied on a fresh development volume; system + checks passed and no pending or missing migrations were detected. +- Runtime permission checks passed for DML, sequences, denied DDL, role + escalation, and privileged role attributes. +- Actual HTTP readiness returned 200; a separate container with an unreachable + database returned generic 503 without connection details. +- Independent review identified a temporary-server readiness race. Both database + healthchecks now require TCP. Fresh-volume migrations, permissions and tests + passed again in the isolated gizmoduck-skeleton-verify Compose project. +- Verification-project containers are stopped; its synthetic database volume is + retained. The main local application remains running. +- Documentation links, whitespace, and candidate files were checked for local + secret values. Existing foundation changes remain uncommitted with this work. diff --git a/docs/superpowers/specs/2026-09-06-application-skeleton-design.md b/docs/superpowers/specs/2026-09-06-application-skeleton-design.md new file mode 100644 index 0000000..ce4b10c --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-application-skeleton-design.md @@ -0,0 +1,103 @@ +# Runnable Application Skeleton Design + +**Date:** 2026-09-06 +**Status:** Approved + +## Goal and scope + +Deliver roadmap step 2: a clean checkout can start Django with PostgreSQL, +apply migrations, and run database-backed pytest checks using documented +commands. No financial domain models, importers, or product UI are included. + +## Approach + +Use Docker Compose for both application and database. This makes the primary +workflow consistent on Windows and avoids requiring host Python dependencies. +A host-run Django environment is a viable alternative but adds setup variation; +a dual supported workflow adds documentation and verification cost without +helping this milestone. Recommend the Compose-only primary workflow. + +Use Python 3.13, Django 5.2 LTS, PostgreSQL 17, psycopg 3, pytest, +pytest-django, and uv with a committed lockfile. Resolve supported patch versions +at implementation time. Freeze dependency installation from the lockfile. +Django documents Python 3.13 compatibility for its 5.2 series: +https://docs.djangoproject.com/en/5.2/releases/5.2/ +PostgreSQL 17 is a supported major version: +https://www.postgresql.org/support/versioning/ + +## Components + +- `config/`: Django settings, URLs, WSGI and ASGI entry points. +- `core/`: a database readiness endpoint; no financial models. +- `manage.py`: normal Django management entry point. +- `pyproject.toml` and `uv.lock`: dependency and pytest configuration. +- `Dockerfile`, `.dockerignore`, and `compose.yaml`: reproducible local services. +- `docker/postgres/`: provisioning scripts, using shell files to avoid the + repository's raw SQL exclusion rules. +- `tests/`: endpoint, database, and permission checks using synthetic data. +- `.env.example` and README setup instructions: configuration contract. + +Expose the development web server only on host loopback port 8000. PostgreSQL +is reachable through the Compose network without a published host port. Persist +its data in a project-scoped Docker volume. Never delete volumes automatically. +The container build context excludes real-data directories, secrets, Git, +virtual environments, and generated artifacts. + +## Configuration and database roles + +Require explicit local secrets in ignored `.env`; commit only non-secret example +values and instructions for generating replacements. Read database connection +settings and the Django secret from the environment. Missing required settings +fail clearly without printing credentials. Restrict allowed hosts to localhost +and 127.0.0.1 for the default local setup. + +Provision three roles: a PostgreSQL bootstrap administrator used only for +initialization, a development/migration role owning the application schema, +and an application runtime role with connect, schema usage, table DML, and +sequence privileges. Runtime has no superuser, role creation, database creation, +or schema creation permissions. Default privileges cover new objects created +by the migration role. The application service receives only runtime credentials. +A separate management service uses migration credentials for explicit migrate +commands. Application startup never silently runs migrations. + +Run tests against a separate PostgreSQL service under a Compose test profile, +with disposable test data and a dedicated test role allowed to create the test +database. Tests cannot target the development database through default settings. +Verify the production-equivalent runtime grants separately against the local +application schema using synthetic records and rollback/cleanup. + +## Behavior and failure handling + +`GET /health/` executes a small database query and returns a minimal JSON status. +Return HTTP 200 on connectivity success or 503 for database connectivity failure; +never return exception messages or connection details. This is a readiness check, +not proof that every migration is applied. Use migration checks independently. + +Compose waits for PostgreSQL readiness. Document the sequence: create local +configuration, build, start the database, run migrations, start web, run tests. +Initialization scripts apply only to new database volumes. Existing-volume role +or credential changes require explicit management; explain this in the README +without recommending destructive resets as the default fix. + +## Verification and acceptance + +1. Validate Compose configuration without echoing resolved secrets. +2. Build the application image using locked dependencies. +3. Initialize PostgreSQL and apply Django's built-in migrations using the + migration role; check that no migrations are pending or missing. +4. Run Django system checks and pytest against the isolated test database. +5. Verify health success and database-failure behavior without exposing errors. +6. Confirm the runtime role can use migrated tables but cannot create tables or + databases, and cannot assume the migration role. +7. Start the web service and request its health endpoint through localhost. +8. Inspect Git changes for secrets and financial data; check ignore rules for + configuration and verify that required source files remain trackable. + +Record actual results, including any Docker availability blocker. No SQLite +fallback is acceptable evidence for PostgreSQL acceptance criteria. + +## Deferred decisions + +Financial models, source parser contracts, authentication UX, product pages, +background tasks, hosted deployment, and backup implementation belong to later +milestones and do not block this skeleton. diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..130b93b --- /dev/null +++ b/manage.py @@ -0,0 +1,7 @@ +import os +import sys +from django.core.management import execute_from_command_line + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + execute_from_command_line(sys.argv) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..157f1b8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "gizmoduck" +version = "0.1.0" +requires-python = ">=3.13,<3.14" +dependencies = ["Django>=5.2,<5.3", "psycopg[binary]>=3.2,<4"] + +[dependency-groups] +dev = ["pytest>=8,<10", "pytest-django>=4.11,<5"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings" +pythonpath = ["."] +testpaths = ["tests"] +addopts = "-ra" diff --git a/tests/check_permissions.py b/tests/check_permissions.py new file mode 100644 index 0000000..878971f --- /dev/null +++ b/tests/check_permissions.py @@ -0,0 +1,76 @@ +"""Run against the migrated development database; all DML is rolled back.""" +import os +import uuid + +import psycopg +from psycopg.errors import InsufficientPrivilege + + +def check_permissions(): + with psycopg.connect(host=os.environ["DB_HOST"], dbname=os.environ["DB_NAME"], + user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], + autocommit=True) as conn: + row = conn.execute("SELECT rolsuper, rolcreatedb, rolcreaterole FROM pg_roles WHERE rolname = current_user").fetchone() + assert row == (False, False, False), "Runtime role has elevated privileges" + with conn.transaction(force_rollback=True): + name = "synthetic_permission_" + uuid.uuid4().hex + pk = conn.execute("INSERT INTO auth_group (name) VALUES (%s) RETURNING id", (name,)).fetchone()[0] + assert conn.execute("SELECT name FROM auth_group WHERE id = %s", (pk,)).fetchone() == (name,) + conn.execute("UPDATE auth_group SET name = %s WHERE id = %s", (name + "_updated", pk)) + assert conn.execute("SELECT name FROM auth_group WHERE id = %s", (pk,)).fetchone() == (name + "_updated",) + conn.execute("DELETE FROM auth_group WHERE id = %s", (pk,)) + assert conn.execute("SELECT id FROM auth_group WHERE id = %s", (pk,)).fetchone() is None + for statement in ["CREATE TABLE public.permission_probe (id integer)", + "CREATE SCHEMA permission_probe", + "SET ROLE gizmoduck_migrate"]: + try: + with conn.transaction(force_rollback=True): + conn.execute(statement) + except InsufficientPrivilege: + pass + else: + raise AssertionError("Runtime unexpectedly permitted: " + statement) + application_tables = conn.execute( + "SELECT tablename FROM pg_tables " + "WHERE schemaname = 'public' " + "AND (tablename LIKE 'finance_%' OR tablename LIKE 'ingestion_%')" + ).fetchall() + assert len(application_tables) >= 11, "Application migrations are missing" + for (table,) in application_tables: + for privilege in ("SELECT", "INSERT", "UPDATE", "DELETE"): + assert conn.execute("SELECT has_table_privilege(current_user, %s, %s)", ("public." + table, privilege)).fetchone()[0], (table, privilege) + with conn.transaction(force_rollback=True): + account_id = uuid.uuid4() + conn.execute("INSERT INTO finance_account (id, name, kind, currency, archived, created_at, updated_at) VALUES (%s, %s, 'BANK', 'BRL', false, now(), now())", (account_id, "synthetic_" + account_id.hex)) + assert conn.execute("SELECT currency FROM finance_account WHERE id = %s", (account_id,)).fetchone() == ("BRL",) + conn.execute("UPDATE finance_account SET archived = true WHERE id = %s", (account_id,)) + assert conn.execute("SELECT archived FROM finance_account WHERE id = %s", (account_id,)).fetchone() == (True,) + conn.execute("DELETE FROM finance_account WHERE id = %s", (account_id,)) + with conn.transaction(force_rollback=True): + batch_id = uuid.uuid4() + conn.execute( + "INSERT INTO ingestion_importbatch " + "(id, created_at, updated_at, document_hash, source_format, " + "profile, parser_version, encoding, context, section_mapping, " + "summary, snapshot, actor_kind, actor_identifier, state, revision, result) " + "VALUES (%s, now(), now(), %s, 'BANK_CSV', 'BANK_CSV_V1', " + "'1', 'utf-8', '{}'::jsonb, '{}'::jsonb, '{}'::jsonb, " + "'{}'::jsonb, 'AGENT', 'synthetic-permission-check', " + "'PREVIEW', 1, '{}'::jsonb)", + (batch_id, "0" * 64), + ) + assert conn.execute( + "SELECT state FROM ingestion_importbatch WHERE id = %s", (batch_id,) + ).fetchone() == ("PREVIEW",) + conn.execute( + "UPDATE ingestion_importbatch SET state = 'CANCELLED' WHERE id = %s", + (batch_id,), + ) + conn.execute("DELETE FROM ingestion_importbatch WHERE id = %s", (batch_id,)) + # PostgreSQL CREATE DATABASE cannot run in a rollback transaction. The + # server's role attributes above verify that capability without creating one. + print("PASS: finance/ingestion DML, sequences, denied DDL and role escalation") + + +if __name__ == "__main__": + check_permissions() diff --git a/tests/test_configuration.py b/tests/test_configuration.py new file mode 100644 index 0000000..670bd92 --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,13 @@ +import os +import subprocess +import sys + + +def test_missing_secret_fails_without_disclosing_other_credentials(): + env = dict(os.environ) + env.pop("DJANGO_SECRET_KEY", None) + env["DB_PASSWORD"] = "private-password-marker" + result = subprocess.run([sys.executable, "manage.py", "check"], env=env, capture_output=True, text=True) + assert result.returncode != 0 + assert "Set DJANGO_SECRET_KEY" in result.stderr + assert "private-password-marker" not in result.stderr diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..aee79dd --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,20 @@ +from unittest.mock import patch + +import pytest +from django.db import OperationalError, connection + + +@pytest.mark.django_db +def test_health_checks_postgresql(client): + assert connection.vendor == "postgresql" + response = client.get("/health/") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_health_failure_does_not_disclose_connection_details(client): + with patch("django.db.backends.postgresql.base.DatabaseWrapper.cursor", side_effect=OperationalError("private-host secret-password")): + response = client.get("/health/") + assert response.status_code == 503 + assert response.json() == {"status": "unavailable"} + assert b"secret-password" not in response.content diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2553f0b --- /dev/null +++ b/uv.lock @@ -0,0 +1,179 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[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 = "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 = "django" +version = "5.2.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" }, +] + +[[package]] +name = "gizmoduck" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "psycopg", extra = ["binary"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-django" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=5.2,<5.3" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8,<10" }, + { name = "pytest-django", specifier = ">=4.11,<5" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[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.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/8fb739d0f6bba247b9b93c9840c402a4f88545be5f1d4b02b23366371c00/psycopg-3.3.5.tar.gz", hash = "sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4", size = 166508, upload-time = "2026-08-31T22:45:43.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/2e/d0a645bcaadde68bd6d93c43f02f14b0191bdda367ce3f7722abe3da744a/psycopg-3.3.5-py3-none-any.whl", hash = "sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e", size = 213598, upload-time = "2026-08-31T22:39:02.184Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1c/e718752cc63cf4e99e4a10fd36e3a3364dabdd0819484a24c0d79fbb9685/psycopg_binary-3.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e85d50b87257fb117675a19ee59daa7bf9a57f6431500adf7059df799232ef4", size = 4704421, upload-time = "2026-08-31T22:42:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/af/cf/a0e748e27c09b92738e4460582d121ba1908be3e36791e150f435e54b332/psycopg_binary-3.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5becd311f9af8d180bad372f51fb2252fd02cb2073056e2b170c9274f95fe7f", size = 4765054, upload-time = "2026-08-31T22:43:05.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/62/0cbac0266d56c94dd1f702d9af2b5d54bf80b6e658048f4f2c5bd63dd7e3/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:19e5bf9872dbd164c220567fd385ba2309c7d9df1541f78343510c6b0f36a1b7", size = 5547137, upload-time = "2026-08-31T22:43:11.768Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3a/73c6f8871f38fc07a9c0b4cbc9467beb116a2783cecf87dfa53900a396dc/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb3b3bffebfe07110730626e76238161124f35ac87b748d663316a28d22f58b0", size = 5227577, upload-time = "2026-08-31T22:43:20.767Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/9f17b9f4d297b774dc574199c4dcd02dadc32a00c4056265918de5c70635/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2111f880add40fb03c60556069ad68e884a0908a74d2debafc603caf93b73552", size = 6824606, upload-time = "2026-08-31T22:43:33.698Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/d84dadd94a004dbbb43ce0579f1f766fdc6b8cbef745090e24bea77b5283/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:40505676b1526b9ea387dace034040a8c8b0bcf984cd6bd4720a2ab15e813586", size = 5060854, upload-time = "2026-08-31T22:43:42.258Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/e5078be2c7d2490c0d6cd4cb7b3601aec44be2fa8b3fe927e9419b06004f/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5816472e3bb05615f33a741e0835043d1f4bf9709ff30d2f4aed71815cfc6b5e", size = 4589511, upload-time = "2026-08-31T22:43:50.012Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/08dfb5b18fa482e025864fd91a022310d0782c42e4cf5dda5d0e010b6790/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:358748fc4c8ccdc0e2bdf55420494930e19c3ade586ea9c3a6de3dad1f897311", size = 4268144, upload-time = "2026-08-31T22:43:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/88/b9/cb01dc1d63f3241b49b2ca7fb9f98d0f5c76127f0dbb9440635a6ad0233e/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1ef2e498be47800f6202b9a2304c22646325ca6d54001b7c785bcfdb24a1e8ab", size = 4001036, upload-time = "2026-08-31T22:44:04.429Z" }, + { url = "https://files.pythonhosted.org/packages/95/49/7c17dd832c05b380562ff2ff5f6ab2bcaeca8b7fff2c2b355854368a5bdb/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88e01aa2e938a45655a8a5213fc3a44ba78cb4cab8a569b3e0bcb3d1d0eaba16", size = 4313112, upload-time = "2026-08-31T22:44:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/b0b2f887185d6a2ec0b3bef948cc07656d2d1a5d96fa7f2bb03f6ef06ca4/psycopg_binary-3.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:ba466011569297114449df9d523438e1adeedf3e4f31ffb78e897ec3fef3076b", size = 3647313, upload-time = "2026-08-31T22:44:19.139Z" }, +] + +[[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-django" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +]