diff --git a/.devcontainer/docker-compose.devcontainer.yml b/.devcontainer/docker-compose.devcontainer.yml index 872421204..6052c3a0c 100644 --- a/.devcontainer/docker-compose.devcontainer.yml +++ b/.devcontainer/docker-compose.devcontainer.yml @@ -12,3 +12,7 @@ services: celerybeat: # Celery beat will be started via launch.json or the terminal. profiles: ["celerybeat"] + + procrastinate: + # The procrastinate worker will be started via launch.json or the terminal. + profiles: ["procrastinate"] diff --git a/.greenmask/config.yml b/.greenmask/config.yml index 031725301..9c29dab1d 100644 --- a/.greenmask/config.yml +++ b/.greenmask/config.yml @@ -60,6 +60,10 @@ dump: - "oauth2_provider_refreshtoken" - "socialaccount_socialtoken" - "socialaccount_socialaccount" + - "procrastinate_events" + - "procrastinate_jobs" + - "procrastinate_periodic_defers" + - "procrastinate_workers" transformation: diff --git a/.vscode/launch.json b/.vscode/launch.json index f35650e1e..a28328d09 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -87,6 +87,20 @@ "order": 3 } }, + { + "name": "Procrastinate: Worker", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "args": ["procrastinate", "worker"], + "django": true, + "console": "integratedTerminal", + "justMyCode": false, + "presentation": { + "group": "2-services", + "order": 4 + } + }, { "name": "Pytest: Debug", "type": "debugpy", @@ -108,7 +122,7 @@ "console": "integratedTerminal", "presentation": { "group": "2-services", - "order": 4 + "order": 5 } } ], diff --git a/Procfile b/Procfile index 796576c99..114e7b294 100644 --- a/Procfile +++ b/Procfile @@ -10,3 +10,4 @@ web: gunicorn --bind 0.0.0.0:$PORT --graceful-timeout 120 --timeout 125 --limit- worker: REMAP_SIGTERM=SIGQUIT ./deploy/worker.sh low_priority_worker: REMAP_SIGTERM=SIGQUIT ./deploy/low-priority-worker.sh beat: REMAP_SIGTERM=SIGQUIT celery --app isic.celery beat --loglevel INFO +procrastinate: ./deploy/procrastinate-worker.sh diff --git a/deploy/procrastinate-worker.sh b/deploy/procrastinate-worker.sh new file mode 100755 index 000000000..fdf3076ba --- /dev/null +++ b/deploy/procrastinate-worker.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +# Procrastinate has no per-task timeout (no soft_time_limit/time_limit equivalent). +# This is a coarse backstop bounding any single statement issued by this process. +# libpq reads PGOPTIONS, and neither Django's connection params nor procrastinate's +# worker pool set "options", so both inherit it. +export PGOPTIONS="-c statement_timeout=30min" + +# Fail at boot on a misconfigured worker rather than idling silently. +python ./manage.py procrastinate healthchecks + +# Heroku sends SIGTERM then SIGKILLs 30 seconds later, so the graceful window has +# to fit inside that or it never actually runs. +python ./manage.py procrastinate worker \ + --queues default,stats-aggregation \ + --concurrency 2 \ + --shutdown-graceful-timeout 25 diff --git a/docker-compose.override.yml b/docker-compose.override.yml index d88bcea27..a3fb4624a 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -82,6 +82,31 @@ services: rabbitmq: condition: service_healthy + procrastinate: + build: + context: . + dockerfile: ./dev/django.Dockerfile + command: [ + "uv", "run", + "./manage.py", "procrastinate", "worker" + ] + # uv progress doesn't display properly with a Docker TTY + tty: false + working_dir: /home/vscode/isic + env_file: ./dev/.env.docker-compose + volumes: + - .:/home/vscode/isic + - pkg_cache:/home/vscode/pkg-cache + depends_on: + postgres: + condition: service_healthy + elasticsearch: + condition: service_healthy + minio: + condition: service_healthy + redis: + condition: service_healthy + zipstreamer: image: ghcr.io/imagemarkup/isic-zipstreamer:master environment: diff --git a/isic/conftest.py b/isic/conftest.py index ccebdbe64..c5c3f0c72 100644 --- a/isic/conftest.py +++ b/isic/conftest.py @@ -7,8 +7,10 @@ from django.core.cache import cache from django.core.files.base import ContentFile from django.core.files.storage import default_storage +from django.db import connection from django.test.client import Client from playwright.sync_api import expect +from procrastinate.contrib.django import DjangoApp, procrastinate_app import pytest from pytest_factoryboy import register @@ -86,6 +88,45 @@ def _clear_cache(): cache.clear() +@pytest.fixture +def run_procrastinate_jobs(): + """ + Run every queued procrastinate job to completion. + + Procrastinate has no equivalent of CELERY_TASK_ALWAYS_EAGER, so a test that + triggers a deferral has to run a worker to observe what the job did. Needs + django_db(transaction=True), since the worker reads jobs the test committed. + """ + + def _run(): + app = procrastinate_app.current_app + # A throwaway app, so the worker doesn't also schedule every periodic task + # into this test's database. It gets the worker connector rather than the + # default Django one, which hands back jsonb as a string and so can't + # decode job arguments. + worker_app = DjangoApp(connector=app.connector.get_worker_connector()) + worker_app.tasks.update(app.tasks) + worker_app.run_worker(wait=False, install_signal_handlers=False, listen_notify=False) + + return _run + + +@pytest.fixture(autouse=True) +def _clear_procrastinate_jobs(request): + # Procrastinate's models are managed=False, so django_table_names() skips them + # and TransactionTestCase never truncates them. Without this, a job deferred by + # a transaction=True test survives into the next one. + yield + + if "django_db_setup" in request.fixturenames: + with connection.cursor() as cursor: + cursor.execute( + "TRUNCATE procrastinate_jobs, procrastinate_events," + " procrastinate_periodic_defers, procrastinate_workers" + " RESTART IDENTITY CASCADE" + ) + + @pytest.fixture def authenticated_client(user): # Do not use the client fixture, to prevent mutating its state diff --git a/isic/core/tasks.py b/isic/core/tasks.py index fc20eb352..ee3595746 100644 --- a/isic/core/tasks.py +++ b/isic/core/tasks.py @@ -14,10 +14,13 @@ from django.core.files.storage import default_storage, storages from django.core.mail import send_mail from django.db import connection, transaction -from django.db.models import Prefetch +from django.db.models import Prefetch, Q from django.template.loader import render_to_string from oauth2_provider.models import clear_expired as clear_expired_oauth_tokens +from procrastinate.contrib.django import app as procrastinate_app +from procrastinate.contrib.django.models import ProcrastinateJob from resonant_utils.storages import expiring_url +import sentry_sdk from urllib3.exceptions import ConnectionError as Urllib3ConnectionError from urllib3.exceptions import TimeoutError as Urllib3TimeoutError @@ -42,6 +45,11 @@ DoiType = Literal["Doi", "DraftDoi"] +MATERIALIZED_VIEW_REFRESH_EXPIRY = timedelta(seconds=60) + +# matches the worker's own stalled_worker_timeout default +STALLED_WORKER_TIMEOUT = timedelta(seconds=30) + @shared_task(soft_time_limit=600, time_limit=610) def populate_collection_from_search_task( @@ -139,7 +147,7 @@ def sync_elasticsearch_indices_task(): cache.delete_pattern("es:*") -@shared_task(soft_time_limit=1800, time_limit=1810) +@procrastinate_app.task() def generate_staff_image_list_metadata_csv_task(user_id: int) -> None: user = User.objects.get(pk=user_id, is_staff=True) @@ -256,13 +264,39 @@ def generate_archive_snapshot_task() -> None: Path(metadata_filename).unlink() -@shared_task(soft_time_limit=10, time_limit=15) -def prune_expired_oauth_tokens_task(): +@procrastinate_app.periodic(cron="*/10 * * * *") +@procrastinate_app.task(queueing_lock="report_stalled_jobs") +@sentry_sdk.monitor(monitor_slug="report-stalled-jobs") +def report_stalled_jobs_task(timestamp: int = 0): + stalled = ProcrastinateJob.objects.filter(status="doing").filter( + Q(worker__isnull=True) + | Q(worker__last_heartbeat__lt=datetime.now(tz=UTC) - STALLED_WORKER_TIMEOUT) + ) + + for job_id, task_name in stalled.values_list("id", "task_name"): + logger.error("Procrastinate job %s is stalled, task %s", job_id, task_name) + + +@procrastinate_app.periodic(cron="0 0 * * *") +@procrastinate_app.task(queueing_lock="prune_expired_oauth_tokens") +@sentry_sdk.monitor(monitor_slug="prune-expired-oauth-tokens") +def prune_expired_oauth_tokens_task(timestamp: int = 0): clear_expired_oauth_tokens() -@shared_task(soft_time_limit=90, time_limit=120) -def refresh_materialized_view_collection_counts_task(): +# queueing_lock keeps at most one refresh waiting to run, replacing half of celery's +# expires=60. The other half is the staleness check below: refreshing is expensive, +# and a job scheduled for a period that has already passed has nothing to add over +# the next one. +@procrastinate_app.periodic(cron="*/15 * * * *") +@procrastinate_app.task(queueing_lock="refresh_materialized_view_collection_counts") +@sentry_sdk.monitor(monitor_slug="refresh-materialized-view-collection-counts") +def refresh_materialized_view_collection_counts_task(timestamp: int = 0): + scheduled_at = datetime.fromtimestamp(timestamp, tz=UTC) if timestamp else None + if scheduled_at and datetime.now(tz=UTC) - scheduled_at > MATERIALIZED_VIEW_REFRESH_EXPIRY: + logger.info("Skipping collection counts refresh scheduled for %s", scheduled_at) + return + with connection.cursor() as cursor: cursor.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY materialized_collection_counts;") diff --git a/isic/core/tests/test_view_image_list.py b/isic/core/tests/test_view_image_list.py index 2d04211f3..84cdcbaa7 100644 --- a/isic/core/tests/test_view_image_list.py +++ b/isic/core/tests/test_view_image_list.py @@ -7,7 +7,9 @@ # needs a real transaction due to setting the isolation level @pytest.mark.django_db(transaction=True) -def test_image_list_metadata_download_view(mocker, staff_client, mailoutbox, user, image: Image): +def test_image_list_metadata_download_view( + mocker, staff_client, mailoutbox, user, image: Image, run_procrastinate_jobs +): image.accession.update_metadata( user, { @@ -26,6 +28,8 @@ def test_image_list_metadata_download_view(mocker, staff_client, mailoutbox, use r = staff_client.get(reverse("core/image-list-metadata-download"), follow=True) assert r.status_code == 200 + run_procrastinate_jobs() + assert len(mailoutbox) == 1 assert spy.call_count == 1 storage, key, _ = spy.call_args[0] diff --git a/isic/core/views/images.py b/isic/core/views/images.py index 79bb8e6b3..1b9315c7c 100644 --- a/isic/core/views/images.py +++ b/isic/core/views/images.py @@ -216,7 +216,7 @@ def staff_image_list_export(request: AuthenticatedHttpRequest) -> HttpResponse: @staff_member_required def staff_image_list_metadata_download(request: AuthenticatedHttpRequest): - generate_staff_image_list_metadata_csv_task.delay_on_commit(request.user.id) + generate_staff_image_list_metadata_csv_task.defer(user_id=request.user.id) messages.add_message( request, diff --git a/isic/settings/base.py b/isic/settings/base.py index e3a580aba..d7e662391 100644 --- a/isic/settings/base.py +++ b/isic/settings/base.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from cachalot.settings import Settings as CachalotSettings from celery.schedules import crontab import django_stubs_ext from environ import Env @@ -68,6 +69,7 @@ # Install "ninja" to force Swagger to be served locally, so it can be overridden "ninja", "oauth2_provider", + "procrastinate.contrib.django", "resonant_utils", "s3_file_field", "template_partials", @@ -178,26 +180,10 @@ CORS_ALLOW_ALL_ORIGINS = True CELERY_BEAT_SCHEDULE = { - "collect-google-analytics-stats": { - "task": "isic.stats.tasks.collect_google_analytics_metrics_task", - "schedule": timedelta(hours=6), - }, "sync-elasticsearch-index": { "task": "isic.core.tasks.sync_elasticsearch_indices_task", "schedule": crontab(minute="0", hour="0"), }, - "prune-expired-oauth-tokens": { - "task": "isic.core.tasks.prune_expired_oauth_tokens_task", - "schedule": crontab(minute="0", hour="0"), - }, - "refresh-materialized-view-collection-counts": { - "task": "isic.core.tasks.refresh_materialized_view_collection_counts_task", - "schedule": crontab(minute="*/15", hour="*"), - "options": { - # to avoid overcomputing, the message should expire 60 seconds after created - "expires": timedelta(seconds=60).total_seconds(), - }, - }, "run-health-checks": { "task": "isic.core.tasks.run_health_checks_task", "schedule": crontab(minute="0", hour="0"), @@ -205,6 +191,8 @@ } CELERY_WORKER_MAX_MEMORY_PER_CHILD = 256 * 1024 +PROCRASTINATE_WORKER_DEFAULTS = {"delete_jobs": "never"} + CACHES = { # use django-redis instead of the builtin backend. the builtin redis backend # doesn't support deleting keys by prefix, which is important for invalidating @@ -217,6 +205,16 @@ # This seems like an essential setting for correctness, see # https://github.com/noripyt/django-cachalot/issues/266 CACHALOT_FINAL_SQL_CHECK = True +# Procrastinate writes to its tables with raw cursor SQL, which never reaches +# cachalot's SQLCompiler hooks. Without this, an ORM read of ProcrastinateJob +# would be cached and never invalidated. +CACHALOT_UNCACHABLE_TABLES = [ + *CachalotSettings.CACHALOT_UNCACHABLE_TABLES, + "procrastinate_events", + "procrastinate_jobs", + "procrastinate_periodic_defers", + "procrastinate_workers", +] MARKDOWNIFY = { "default": { @@ -278,6 +276,9 @@ ISIC_USE_ELASTICSEARCH_COUNTS = True # opensearch logs every single request, which is too verbose logging.getLogger("elastic_transport").setLevel(logging.WARNING) +# procrastinate logs every task registration at import time, in every process +logging.getLogger("procrastinate.blueprints").setLevel(logging.WARNING) +logging.getLogger("procrastinate.periodic").setLevel(logging.WARNING) ISIC_DATACITE_API_URL: ParseResult | None = env.url("DJANGO_ISIC_DATACITE_API_URL", default=None) # These are the default styles with their proper names that are used by the diff --git a/isic/settings/testing.py b/isic/settings/testing.py index 424230eed..a0aad2b19 100644 --- a/isic/settings/testing.py +++ b/isic/settings/testing.py @@ -63,6 +63,12 @@ CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_EAGER_PROPAGATES = True +# Don't hold persistent connections in tests. The procrastinate worker runs tasks +# in an asgiref thread pool, and close_old_connections() respects CONN_MAX_AGE, so +# a healthy persistent connection in one of those threads would outlive the test +# and block the test database from being dropped. +DATABASES["default"]["CONN_MAX_AGE"] = 0 + ISIC_ELASTICSEARCH_IMAGES_INDEX = "test-isic-images" ISIC_ELASTICSEARCH_LESIONS_INDEX = "test-isic-lesions" ISIC_USE_ELASTICSEARCH_COUNTS = False diff --git a/isic/stats/tasks.py b/isic/stats/tasks.py index b8d2fad61..af472c482 100644 --- a/isic/stats/tasks.py +++ b/isic/stats/tasks.py @@ -19,7 +19,10 @@ from django.db.models import Max from django.db.utils import IntegrityError from django.utils import timezone +import procrastinate +from procrastinate.contrib.django import app as procrastinate_app import pycountry +import sentry_sdk from isic.core.models.image import Image from isic.stats.models import GaMetrics, ImageDownload, LastEnqueuedS3Log @@ -68,7 +71,7 @@ def _get_google_analytics_report(client, property_id: str) -> GoogleAnalyticsRep metrics=[Metric(name="sessions")], date_ranges=[DateRange(start_date="30daysAgo", end_date="today")], ) - response = client.run_report(request) + response = client.run_report(request, timeout=30) for row in response.rows: country_id, sessions = row.dimension_values[0].value, row.metric_values[0].value @@ -95,12 +98,16 @@ def _country_from_iso_code(iso_code: str) -> dict: } -@shared_task( - soft_time_limit=60, - time_limit=120, +@procrastinate_app.periodic(cron="0 */6 * * *") +@procrastinate_app.task( queue="stats-aggregation", + queueing_lock="collect_google_analytics_metrics", + # exponential_wait is a base, not a duration: the formula is + # wait + linear_wait * attempts + exponential_wait ** (attempts + 1). + retry=procrastinate.RetryStrategy(max_attempts=3, linear_wait=120), ) -def collect_google_analytics_metrics_task(): +@sentry_sdk.monitor(monitor_slug="collect-google-analytics-stats") +def collect_google_analytics_metrics_task(timestamp: int = 0): client = _get_analytics_client() num_sessions = 0 sessions_per_country = [] diff --git a/pyproject.toml b/pyproject.toml index 05f960e4a..0c4c61557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,8 @@ dependencies = [ "numpy==2.5.1", "pandas==3.0.5", "Pillow==12.3.0", - "psycopg[binary]==3.3.4", + "procrastinate[django]==3.9.0", + "psycopg[binary,pool]==3.3.4", "pycountry==26.2.16", "pydantic==2.13.4", "pyexiv2==2.15.5", diff --git a/rules.yml b/rules.yml index a12743978..06294bf72 100644 --- a/rules.yml +++ b/rules.yml @@ -122,6 +122,35 @@ rules: metavariable: $FUNC regex: ^(?!.*_task$).* + - id: require-procrastinate-task-suffix + languages: + - python + severity: ERROR + message: Procrastinate task functions must have a _task suffix. + patterns: + - pattern: | + @$APP.task(...) + def $FUNC(...): + ... + - metavariable-regex: + metavariable: $FUNC + regex: ^(?!.*_task$).* + + - id: require-procrastinate-monitor + languages: + - python + severity: ERROR + message: Periodic procrastinate tasks must check in with a Sentry cron monitor. + patterns: + - pattern: | + @$APP.periodic(...) + def $FUNC(...): + ... + - pattern-not: | + @sentry_sdk.monitor(...) + def $FUNC(...): + ... + - id: django-foreignkey-requires-related-name languages: - python diff --git a/uv.lock b/uv.lock index c8faa1ecb..d71468021 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.13" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and platform_machine != 'arm64' and sys_platform == 'darwin'", "python_full_version >= '3.15' and platform_machine == 'arm64' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and platform_machine != 'arm64' and sys_platform == 'darwin'", "python_full_version == '3.14.*' and platform_machine == 'arm64' and sys_platform == 'darwin'", @@ -625,6 +625,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] +[[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 = "cryptography" version = "49.0.0" @@ -1201,9 +1213,9 @@ name = "gdal" version = "3.11.0" source = { registry = "https://girder.github.io/large_image_wheels/" } resolution-markers = [ + "python_full_version >= '3.15' and platform_machine != 'arm64' and sys_platform == 'darwin'", "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.15' and platform_machine != 'arm64' and sys_platform == 'darwin'", "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and platform_machine != 'arm64' and sys_platform == 'darwin'", "python_full_version < '3.14' and sys_platform == 'win32'", @@ -1646,7 +1658,8 @@ dependencies = [ { name = "pandas" }, { name = "pgvector" }, { name = "pillow" }, - { name = "psycopg", extra = ["binary"] }, + { name = "procrastinate", extra = ["django"] }, + { name = "psycopg", extra = ["binary", "pool"] }, { name = "pyarrow" }, { name = "pycountry" }, { name = "pydantic" }, @@ -1759,7 +1772,8 @@ requires-dist = [ { name = "pandas", specifier = "==3.0.5" }, { name = "pgvector", specifier = "==0.5.0" }, { name = "pillow", specifier = "==12.3.0" }, - { name = "psycopg", extras = ["binary"], specifier = "==3.3.4" }, + { name = "procrastinate", extras = ["django"], specifier = "==3.9.0" }, + { name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" }, { name = "pyarrow", specifier = "==24.0.0" }, { name = "pycountry", specifier = "==26.2.16" }, { name = "pydantic", specifier = "==2.13.4" }, @@ -2640,6 +2654,29 @@ 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 = "procrastinate" +version = "3.9.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/ab/bc/adfe64992725143791ea78c33e9a0778a97f67c0ca8761bb6fd269fbb0be/procrastinate-3.9.0.tar.gz", hash = "sha256:5805ab2af35eab12befa700ecd49e572c4f655d654151df9e5ce1ca07efb5e6e", size = 89448, upload-time = "2026-06-20T23:09:14.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/0c/17bfd406fe4bf85e8a0cfbb9744271df447004233da295fb6e2c10dc29e1/procrastinate-3.9.0-py3-none-any.whl", hash = "sha256:af4b9ccaeebbf2a1439e02ae1e8ce327f8436a81e9d68a0b1bd26d5e7ac697bd", size = 153597, upload-time = "2026-06-20T23:09:13.105Z" }, +] + +[package.optional-dependencies] +django = [ + { name = "django" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2723,6 +2760,9 @@ wheels = [ binary = [ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] +pool = [ + { name = "psycopg-pool" }, +] [[package]] name = "psycopg-binary" @@ -2753,6 +2793,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -4058,10 +4110,10 @@ name = "zipfile-deflate64" version = "0.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and platform_machine != 'arm64' and sys_platform == 'darwin'", "python_full_version >= '3.15' and platform_machine == 'arm64' and sys_platform == 'darwin'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and platform_machine != 'arm64' and sys_platform == 'darwin'", "python_full_version < '3.14' and sys_platform == 'win32'",