diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a023b54..ac842e0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ jobs: - uses: pre-commit/action@main test: - name: Test (py${{ matrix.python-version }} / dj${{ matrix.django-version }}) + name: Test (py${{ matrix.python-version }} / dj${{ matrix.django-version }} / celery${{ matrix.celery-version }}) runs-on: ubuntu-latest needs: pre_commit_checks permissions: @@ -45,18 +45,25 @@ jobs: strategy: fail-fast: false matrix: + # celery is a dev-only dependency (used by the celery_health probes). + # Each row pins a celery series via ``~=.0`` so we exercise the + # 5.3 -> 5.6 range. celery 5.6 requires Python >=3.9, so py3.8 caps at 5.5. include: - python-version: "3.8" django-version: "3.2" + celery-version: "5.3" - python-version: "3.11" django-version: "4.2" + celery-version: "5.4" - python-version: "3.12" django-version: "5.2" + celery-version: "5.5" - python-version: "3.13.2" django-version: "6.0" + celery-version: "5.6" steps: - uses: actions/checkout@main @@ -79,4 +86,5 @@ jobs: run: | uv run \ --with "django~=${{ matrix.django-version }}" \ + --with "celery~=${{ matrix.celery-version }}.0" \ pytest tests/ -v --tb=short diff --git a/.gitignore b/.gitignore index 3c07717..99b31c8 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,7 @@ deploy/eb/ # db db.sqlite3 + +# AI tools +# TODO: Check if we should track this instead? +.serena/ diff --git a/README.md b/README.md index 5a10071..8b64dd2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Reusable Django utilities and management commands for Toggle projects. — Wait for database, Redis, Minio (S3) resources to be available before startup - Create Initial Users: `create_initial_users` - Create Users with specified roles and permissions, useful to populate the database with default users during development or testing +- Health endpoints: `HealthProbeMiddleware` + - Dependency-free `/healthz/live/` and `/healthz/ready/` endpoints for Kubernetes liveness/readiness probes +- Celery worker & beat health: `banjo-celery-probe` + `banjo_utils.celery_health` + - Heartbeat-file liveness probes for the non-HTTP celery worker and beat processes --- @@ -50,19 +54,20 @@ banjo-utils = { git = "https://github.com/toggle-corp/banjo-utils", tag = "v0.1. **Access the management command:** ```bash -python manage.py wait_for_resources --db --redis +python manage.py wait_for_resources --db --cache ``` **Command options:** - `--db`: Wait for database -- `--redis`: Wait for Redis server +- `--cache`: Wait for the Django cache backend (requires a redis client in your project, e.g. via `django-redis` or `redis`; it is not a banjo-utils runtime dependency) - `--minio`: Wait for Minio (S3 storage) +- `--celery-broker`: Wait for the Celery broker at `CELERY_BROKER_URL` (requires `kombu`, installed with celery; it is not a banjo-utils runtime dependency) - `--timeout`: Set max wait time (seconds) **Examples:** ```bash -python manage.py wait_for_resources --db --redis -python manage.py wait_for_resources --timeout 300 --minio +python manage.py wait_for_resources --db --cache +python manage.py wait_for_resources --timeout 300 --minio --celery-broker python manage.py create_initial_users --users-json=" [ { @@ -84,6 +89,257 @@ python manage.py create_initial_users --users-json=" --- +## Health endpoints + +`HealthProbeMiddleware` serves dependency-free endpoints for Kubernetes liveness +and readiness probes: + +- `GET /healthz/live/` — liveness. Returns `200 ok` if the WSGI process can + answer HTTP. A failure tells the kubelet to restart the pod. +- `GET /healthz/ready/` — readiness. Returns `200 ok` once the process is up. A + failure pulls the pod from the Service endpoints. + +Both endpoints check only pod-local health. Shared dependencies (DB, Redis, S3) +are deliberately **not** checked: a shared-dependency outage would fail every +pod's readiness at once, draining the Service to zero endpoints and stampeding +the dependency on recovery. Use a deep health-check tool (e.g. +[`django-health-check`](https://github.com/revsys/django-health-check)) plus +external monitoring for that. + +**Register the middleware first** in `settings.py`, so probe requests +short-circuit before any other middleware runs (this also bypasses the +`ALLOWED_HOSTS` check, so the kubelet may hit the pod by IP): + +```python +MIDDLEWARE = [ + "banjo_utils.health.HealthProbeMiddleware", + "django.middleware.security.SecurityMiddleware", + # ... your other middleware ... +] +``` + +Both trailing-slash and slash-less forms are accepted (`/healthz/live` and +`/healthz/live/`). Override the paths via settings if they conflict with +existing routes: + +```python +BANJO_HEALTH_PROBE_LIVE_URL = "/healthz/live/" +BANJO_HEALTH_PROBE_READY_URL = "/healthz/ready/" +``` + +> Probe scheduling (intervals, thresholds) and graceful-shutdown draining +> (`preStop`) are deploy-side concerns configured in your Kubernetes manifests +> / Helm chart, not here. + +### Keeping probe requests out of Sentry + +`banjo-utils` never talks to Sentry, and these endpoints raise no exceptions, so +**error** monitoring stays quiet. **Performance/tracing** is different: Sentry +instruments requests at the WSGI/ASGI layer — outside Django's middleware stack +— so probe hits create transactions (and consume quota) even though the +middleware short-circuits them. Filter them out in your `sentry_sdk.init`: + +```python +import sentry_sdk +from banjo_utils.health import make_sentry_traces_sampler_with_health_probe_ignore + +# Use directly with init — pass your normal sample rate as the fallback... +sentry_sdk.init(traces_sampler=make_sentry_traces_sampler_with_health_probe_ignore(0.1)) + +# ...or wrap your own sampler (it's called for non-probe requests). +sentry_sdk.init(traces_sampler=make_sentry_traces_sampler_with_health_probe_ignore(my_sampler)) +``` + +If you maintain your own `traces_sampler`, pair the `is_health_probe_path` +predicate with the `extract_sentry_request_health_probe_path` helper (which reads the path out of +Sentry's WSGI/ASGI sampling context): + +```python +from banjo_utils.health import extract_sentry_request_health_probe_path, is_health_probe_path + +def traces_sampler(sampling_context): + if is_health_probe_path(extract_sentry_request_health_probe_path(sampling_context)): + return 0.0 + return 0.1 +``` + +`is_health_probe_path` is just a path predicate, so it also drops into a +`before_send` hook or a logging filter — pass it whatever path that context +exposes (`event["request"]["url"]`, `record.args`, `request.path`, …). All +helpers honour the `BANJO_HEALTH_*` URL overrides above. + +### Keeping probe requests out of the access log + +The kubelet hits `/healthz/live/` and `/healthz/ready/` every few seconds, so +they flood the WSGI server's access log and drown out real traffic. Drop them at +the server layer. + +#### uWSGI + +Use an internal-routing `donotlog` rule in your `uwsgi.ini`: + +```ini +# Match both trailing-slash and slash-less probe URLs. +route = ^/healthz/(live|ready)/?$ donotlog: +``` + +> **Gotcha — needs PCRE.** `route` is silently ignored unless uWSGI was built +> with PCRE support. The PyPI `uwsgi` sdist compiles at install time, so the +> **build** environment must have the PCRE headers (`libpcre3-dev` on +> Debian/Ubuntu) present *before* `pip`/`uv` builds uwsgi — and the `libpcre3` +> runtime lib must survive into the final image. Verify with `ldd $(which uwsgi) +> | grep pcre`; at startup, a PCRE-less build prints `!!! no internal routing +> support, rebuild with pcre support !!!`. If uwsgi is served from a warm +> build cache (e.g. uv's persistent cache keyed on an unchanged lockfile), evict +> it (`uv cache clean uwsgi`) so it recompiles against the newly-present headers. + +> **Caveat.** `donotlog` is unconditional — it also suppresses probe `5xx` +> responses. uWSGI can't make logging status-code-conditional; use gunicorn (or +> a proxy that can) if you need to keep logging probe errors. + +#### gunicorn + +gunicorn logs access lines through the stdlib `logging` module, so attach a +filter to the `gunicorn.access` logger in your `gunicorn.conf.py`. Because the +filter sees the whole access record, it can keep the probe's non-2xx responses — +avoiding the uWSGI caveat above: + +```python +# gunicorn.conf.py +import logging + +from banjo_utils.health import is_health_probe_path + + +class _IgnoreHealthyProbes(logging.Filter): + def filter(self, record): + # gunicorn access atoms: 'U' = path (no query), 's' = status code. + atoms = record.args if isinstance(record.args, dict) else {} + is_probe = is_health_probe_path(atoms.get("U")) + is_ok = str(atoms.get("s", "")).startswith("2") + return not (is_probe and is_ok) # keep probe 4xx/5xx, drop probe 2xx + + +def on_starting(server): + logging.getLogger("gunicorn.access").addFilter(_IgnoreHealthyProbes()) +``` + +> Drop the `is_ok` check (`return not is_probe`) to silence probes +> unconditionally, matching uWSGI's `donotlog` behaviour. + +--- + +## Celery worker & beat health + +Celery worker and beat are **non-HTTP** processes, so `HealthProbeMiddleware` +above does not apply to them. Instead each process periodically *touches* a +0-byte heartbeat file, and the kubelet runs the stdlib `banjo-celery-probe` +reader as an `exec` **liveness** probe that `stat()`s the file and fails when +it is missing or stale. + +Same philosophy as the HTTP probes: pod-local, cheap, dependency-free, detects +a *wedged* process. It deliberately does **no** broker round-trip — `celery +inspect ping` was rejected because it causes false-positive restarts under load +and adds broker CPU cost (see +[celery/celery#4079](https://github.com/celery/celery/issues/4079)). + +Neither `celery` nor `django-celery-beat` is a runtime dependency of +banjo-utils — a celery project already has celery installed, and beat users add +`django-celery-beat` themselves. + +### The probe reader + +`banjo-celery-probe` is installed as a console script (stdlib-only; it imports +neither celery nor django, so it stays fast and works in stripped images): + +```bash +banjo-celery-probe [--heartbeat-file PATH] [--max-age SECONDS] +``` + +It exits `0` if the file was touched within `--max-age` seconds (default +`120`), and `1` if the file is **stale or missing**. The path is resolved from, +in order: the `--heartbeat-file` arg, the `BANJO_CELERY_HEARTBEAT_FILE` env var +(inherited from the writer's container), then a code default. + +> The reader's `--max-age` is just a small staleness check. The real +> wedge-detection budget is the kubelet's `failureThreshold × periodSeconds`, +> set in your manifest/Helm chart (same way `api.probes` already reasons). Use +> a `startupProbe` reusing the same exec command for boot grace rather than +> `initialDelaySeconds`. + +### Worker + +Register the heartbeat bootstep right after creating your Celery app, e.g. in +`main/celery.py`: + +```python +from celery import Celery +from banjo_utils.celery_health.worker import setup_worker_heartbeat + +app = Celery("main") +setup_worker_heartbeat(app) # touches /tmp/celery_worker_heartbeat every 30s +``` + +The bootstep runs on the worker's own `Timer` (in the parent `MainProcess`). +Under the default **prefork** pool, long-running tasks execute in forked child +processes and never stall the timer, so the heartbeat keeps advancing while +real work is in flight. + +> **Caveat:** under the `gevent`/`eventlet` pools the timer shares the single +> event loop with tasks, so a CPU-bound or blocking task can stall the +> heartbeat and trigger a liveness restart. The bootstep targets prefork. + +### Beat + +Beat is customised via celery's `--scheduler` flag (not a bootstep). The +scheduler touches the heartbeat file **after** each `tick()` completes, proving +a tick actually ran: + +```bash +# Primary path: django-celery-beat's DatabaseScheduler (most projects) +celery -A main beat --scheduler banjo_utils.celery_health.database.HeartbeatDatabaseScheduler + +# Plain celery beat (no django-celery-beat) +celery -A main beat --scheduler banjo_utils.celery_health.persistent.HeartbeatPersistentScheduler +``` + +To wrap any other scheduler base, mix in `HeartbeatSchedulerMixin`: + +```python +from banjo_utils.celery_health import HeartbeatSchedulerMixin +from somewhere import SomeScheduler + +class HeartbeatSomeScheduler(HeartbeatSchedulerMixin, SomeScheduler): + pass +``` + +Beat writes `/tmp/celery_beat_heartbeat` by default — a **different** file from +the worker — so point the beat probe at it via `--heartbeat-file` or +`BANJO_CELERY_HEARTBEAT_FILE` (the reader's own default targets the worker +file). The reader's `120s` default `--max-age` suits `DatabaseScheduler` (tick +cadence ~5s → ample margin). For `HeartbeatPersistentScheduler` the +`max_interval` is 300s, so set `--max-age` **above** 300s. + +### Deployment notes + +These are manifest/Helm concerns (no code in banjo-utils): + +- **Graceful shutdown is automatic on `SIGTERM`** — celery's warm shutdown + stops accepting new tasks and drains in-flight ones. The only lever is + `terminationGracePeriodSeconds`. +- **Beat must be a singleton.** Deploy it with `strategy: Recreate` (or + `maxSurge: 0`) so a rolling update never runs two beats at once — duplicate + schedulers fire duplicate scheduled tasks. +- **Heartbeat file storage.** The file is 0 bytes (`touch` only bumps mtime), + but a container's `/tmp` is the node-disk-backed overlay layer. To avoid disk + writes (SSD wear) and to satisfy `readOnlyRootFilesystem: true`, mount a tiny + RAM-backed `emptyDir { medium: Memory, sizeLimit: 1Mi }` at the heartbeat + directory and set `BANJO_CELERY_HEARTBEAT_FILE` to a path inside it. +- **Celery version:** 5.6.1 broke heartbeat-file creation; fixed in **5.6.2**. + Use ≥5.6.2 (or a 5.5.x release). + +--- + ## Development 1. Clone the repository @@ -102,7 +358,7 @@ python manage.py create_initial_users --users-json=" 4. Run commands for example project ```bash uv run --all-groups --all-extras python example/manage.py runserver - uv run --all-groups --all-extras python example/manage.py wait_for_resources --db --redis + uv run --all-groups --all-extras python example/manage.py wait_for_resources --db --cache --celery-broker ``` --- diff --git a/docs/adr/0001-celery-health-via-heartbeat-file.md b/docs/adr/0001-celery-health-via-heartbeat-file.md new file mode 100644 index 0000000..64fae54 --- /dev/null +++ b/docs/adr/0001-celery-health-via-heartbeat-file.md @@ -0,0 +1,113 @@ +--- +status: accepted +date: 2026-06-16 +deciders: [thenav56] +--- + +# 0001. Celery worker & beat liveness via a pod-local heartbeat file + +## Context and Problem Statement + +Celery worker and beat are long-running, **non-HTTP** processes, so the +existing `HealthProbeMiddleware` (which serves `/healthz/*` over HTTP) cannot +report their health to Kubernetes. We need a liveness signal that tells the +kubelet to restart a worker/beat pod when its process has *wedged* (deadlocked, +hung on a stuck task, or beat's scheduler loop stopped ticking) — without the +probe itself becoming a source of instability under load. + +The forces: a liveness probe runs on every pod on a fixed interval (often +several pods × every ~10s), it must be cheap and self-contained, and a +false-positive failure restarts a healthy pod — which under load can cascade. + +## Considered Options + +- **A. `celery inspect ping`** — the documented worker health command; sends a + control message over the broker and waits for a reply. +- **B. PID-file / process-existence check** — assert the celery process is + still running. +- **C. Pod-local heartbeat file** — the process periodically touches a file; + an exec probe checks the file's freshness. +- **D. Deep HTTP health endpoint** (e.g. `django-health-check`) — a service + that actively checks broker/DB connectivity. + +## Decision Outcome + +Chosen: **Option C — a pod-local heartbeat file.** + +- The **worker** runs a `StartStopStep` bootstep on its own `Timer` (in the + parent `MainProcess`) that touches a heartbeat file every 30s. Under the + prefork pool, tasks run in forked children and never stall the timer. +- **Beat** is customised via `--scheduler`; the scheduler touches its heartbeat + file *after* each `tick()` returns, proving a tick completed. +- A stdlib-only reader (`banjo-celery-probe`) is run by the kubelet as an + `exec` liveness probe: it `stat()`s the file and exits non-zero when the file + is stale or missing. It imports neither celery nor django. + +This mirrors the philosophy already established for the HTTP probes in +`src/banjo_utils/health.py`: **pod-local, cheap, dependency-free, and scoped to +"is this process wedged?"** — not "is the whole system healthy?". + +## Consequences + +- **Good:** no broker traffic per probe (no false-positive restarts under load, + no broker CPU cost); detects a genuinely wedged process, not just a live PID; + the reader is dependency-free and fast, so it works in stripped images and + cannot itself hang on a network call. +- **Bad:** detection latency is ~2 min (reader's 120s default `--max-age` ≈ 4× + the 30s write interval), not instant — acceptable for liveness. +- **Bad:** the probe deliberately does **not** verify broker/DB connectivity; a + worker that is up but cannot reach the broker still looks "live". That is + intentional (shared-dependency checks belong in monitoring, not liveness), but + it is a real limitation to be aware of. +- **Bad:** the worker heartbeat assumes the **prefork** pool. Under + `gevent`/`eventlet` the timer shares the event loop with tasks, so a blocking + task can stall the heartbeat and cause a false restart. +- **Bad:** the beat reader's `--max-age` must exceed the scheduler's tick + cadence — fine for `DatabaseScheduler` (~5s), but `PersistentScheduler`'s + `max_interval` is 300s, so its probe needs `--max-age > 300`. This cadence is + also **operator-overridable**: `celery beat --max-interval ` raises + the scheduler's `max_interval` regardless of scheduler class, so beat sleeps + (and skips touching the heartbeat) for up to that long between ticks. A value + above the probe's `--max-age` silently causes false-positive restarts of a + healthy beat — keep `--max-interval` below `--max-age`. +- **Cost:** the writer must be wired into project code (`setup_worker_heartbeat` + / the `--scheduler` flag), and the heartbeat directory should be a small + RAM-backed `emptyDir` to avoid disk wear — a manifest/Helm concern. +- **Revisit if:** projects move predominantly to a `gevent`/`eventlet` pool, or + if we decide liveness *should* assert broker connectivity (which would + reintroduce the Option A tradeoffs). + +## Pros and Cons of the Options + +### A. `celery inspect ping` + +- Good: directly exercises the worker's ability to process a control message. +- Bad: every probe is a broker round-trip; under load workers are too busy to + answer promptly, producing **false-positive restarts**, and the extra control + traffic adds broker CPU cost. This is the documented failure mode in + [celery/celery#4079](https://github.com/celery/celery/issues/4079) and the + primary reason this option was rejected. +- Bad: the probe depends on celery being importable and the broker reachable — + the opposite of a self-contained liveness check. + +### B. PID-file / process-existence check + +- Good: trivially cheap and dependency-free. +- Bad: a deadlocked or hung process keeps its PID, so this **cannot detect a + wedge** — only a fully dead process. Insufficient for the actual requirement. + (Per the discussion in celery/celery#4079: "PID is not a solution, can't + catch deadlock.") + +### C. Pod-local heartbeat file (chosen) + +- Good: cheap, self-contained, no broker traffic; advances only while the + process is actually doing work, so it catches wedges. +- Bad: ~2 min detection latency and the pool/cadence caveats noted above. + +### D. Deep HTTP health endpoint + +- Good: can report rich, system-wide health (broker, DB, storage). +- Bad: wrong tool for *liveness* — a shared-dependency outage would fail every + pod at once. This is the deep-health/monitoring concern that `health.py` + already documents as deliberately out of scope for probes. Complementary to, + not a replacement for, this decision. diff --git a/pyproject.toml b/pyproject.toml index 908580d..f2d6458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ authors = [ dependencies = [ "Django>=3.2", "httpx>=0.28.1", - "django-redis>=5.3.0,<6" ] [dependency-groups] @@ -26,6 +25,19 @@ dev = [ "django-stubs", "colorlog", "types-factory-boy>=0.4.1", + # celery / django-celery-beat are NOT runtime dependencies of banjo-utils + # (see celery_health/database.py). They live here so the celery health + # bootstep, scheduler mixins and type-checking/tests have them available. + "celery", + "django-celery-beat", + # django-redis is NOT a runtime dependency either: banjo-utils only touches + # Django's abstract cache API, so a redis-backed consumer installs + # django-redis themselves (it brings the ``redis`` client transitively). + # It lives here so ``wait_for_resources --redis`` tests can import + # ``redis.exceptions``. Left unpinned and untested-across-versions (unlike + # celery) because nothing imports ``django_redis`` and the redis path is + # fully mocked -- there is no version-sensitive surface to exercise. + "django-redis", ] test = [ "pytest-django", @@ -35,6 +47,11 @@ test = [ "pytest-profiling", ] +[project.scripts] +# Stdlib-only liveness probe reader for celery worker/beat heartbeats, run as a +# Kubernetes exec probe. See banjo_utils.celery_health. +banjo-celery-probe = "banjo_utils.celery_probe:main" + [project.urls] Homepage = "https://github.com/toggle-corp/banjo-utils" Repository = "https://github.com/toggle-corp/banjo-utils" diff --git a/src/banjo_utils/celery_health/__init__.py b/src/banjo_utils/celery_health/__init__.py new file mode 100644 index 0000000..362088e --- /dev/null +++ b/src/banjo_utils/celery_health/__init__.py @@ -0,0 +1,88 @@ +"""Pod-local liveness heartbeats for celery worker & beat processes. + +Celery worker and beat are non-HTTP processes, so the HTTP +``HealthProbeMiddleware`` in :mod:`banjo_utils.health` does not apply. Instead +each process periodically *touches* a heartbeat file, and the kubelet runs the +stdlib :mod:`banjo_utils.celery_probe` reader as an ``exec`` liveness probe to +``stat()`` that file. Mirrors the philosophy of ``health.py``: pod-local, +cheap, dependency-free, detects a wedged process -- and deliberately avoids +per-probe broker round-trips (``celery inspect ping`` was rejected for its +false-positive restarts under load and broker CPU cost; see +https://github.com/celery/celery/issues/4079). + +This package is split so the dependency surface stays minimal: + +- This module (``__init__``) holds :class:`HeartbeatSchedulerMixin`, which is + **base-agnostic and has no runtime celery/django dependency** -- celery is + only referenced under ``TYPE_CHECKING``. +- :mod:`~banjo_utils.celery_health.persistent` subclasses celery's own + ``PersistentScheduler`` (celery-only, the dependency-free test vehicle). +- :mod:`~banjo_utils.celery_health.database` subclasses + ``django_celery_beat``'s ``DatabaseScheduler`` (the primary path for most + projects); it imports its base at class-definition time, so it lives in its + own submodule and is only loaded when ``--scheduler`` points at it. +- :mod:`~banjo_utils.celery_health.worker` holds the worker bootstep. + +Neither ``celery`` nor ``django-celery-beat`` is a runtime dependency of +banjo-utils: a celery project already has celery installed, and beat users add +``django-celery-beat`` themselves. +""" + +from __future__ import annotations + +import os +import typing +from pathlib import Path + +from banjo_utils.celery_probe import HEARTBEAT_FILE_ENV + +# Beat writes a different file than the worker so the two heartbeats never +# clobber one another when wrongly configured to share a dir. A beat liveness +# probe must therefore point the reader at this path (via --heartbeat-file or +# the BANJO_CELERY_HEARTBEAT_FILE env), since the reader's own default targets +# the worker file. +DEFAULT_BEAT_HEARTBEAT_FILE = "/tmp/celery_beat_heartbeat" # noqa: S108 + + +def resolve_writer_heartbeat_file(default: str) -> str: + """Resolve the path a writer touches: ``BANJO_CELERY_HEARTBEAT_FILE`` or ``default``.""" + return os.environ.get(HEARTBEAT_FILE_ENV) or default + + +def touch_heartbeat(path: str) -> None: + """Create ``path`` if missing and bump its mtime (a 0-byte heartbeat).""" + Path(path).touch() + + +class HeartbeatSchedulerMixin: + """Mix into any celery beat ``Scheduler`` to touch a heartbeat file each tick. + + Override the scheduler with ``celery beat --scheduler ``. The + file is touched **after** ``super().tick()`` returns, so the heartbeat only + advances when a tick actually completes (a wedged tick goes stale and the + liveness probe restarts the pod). + + The path comes from ``BANJO_CELERY_HEARTBEAT_FILE`` (set by the chart), + falling back to :data:`heartbeat_file_default`. + + .. warning:: + The heartbeat advances **only as often as beat ticks**, and the tick + cadence is capped by the scheduler's ``max_interval``. The probe's + ``--max-age`` must therefore exceed that cap, or a perfectly healthy beat + gets restarted between ticks. ``DatabaseScheduler`` defaults to ~5s + (fine), but ``max_interval`` is operator-overridable via + ``celery beat --max-interval ``: passing e.g. ``--max-interval + 3600`` makes beat sleep up to an hour between ticks, so the heartbeat + goes stale and the probe false-restarts beat. Keep ``--max-interval`` + (if set at all) below the probe's ``--max-age``. + """ + + #: Per-class default path; overridden by the env var at tick time. + heartbeat_file_default: typing.ClassVar[str] = DEFAULT_BEAT_HEARTBEAT_FILE + + def tick(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + # ``super()`` is the real scheduler base this mixin is combined with; + # cast to Any since the mixin itself is base-agnostic (no celery dep). + result = typing.cast("typing.Any", super()).tick(*args, **kwargs) + touch_heartbeat(resolve_writer_heartbeat_file(self.heartbeat_file_default)) + return result diff --git a/src/banjo_utils/celery_health/database.py b/src/banjo_utils/celery_health/database.py new file mode 100644 index 0000000..705b36e --- /dev/null +++ b/src/banjo_utils/celery_health/database.py @@ -0,0 +1,28 @@ +"""Heartbeat scheduler for ``django-celery-beat``'s ``DatabaseScheduler``. + +This is the **primary** beat path -- most projects schedule via the database. +Use it with:: + + celery -A proj beat --scheduler banjo_utils.celery_health.database.HeartbeatDatabaseScheduler + +``django-celery-beat`` is **not** a runtime dependency of banjo-utils, so this +module is isolated: importing it (only when ``--scheduler`` points here) is +what pulls in the base class. If the package is missing we re-raise with a +friendly, actionable message. +""" + +from __future__ import annotations + +try: + from django_celery_beat.schedulers import DatabaseScheduler +except ImportError as e: + raise ImportError( + "HeartbeatDatabaseScheduler requires django-celery-beat, " + "which is not a banjo-utils runtime dependency. Install it in your project.", + ) from e + +from banjo_utils.celery_health import HeartbeatSchedulerMixin + + +class HeartbeatDatabaseScheduler(HeartbeatSchedulerMixin, DatabaseScheduler): + """``DatabaseScheduler`` that touches a heartbeat file after each tick.""" diff --git a/src/banjo_utils/celery_health/persistent.py b/src/banjo_utils/celery_health/persistent.py new file mode 100644 index 0000000..27f0e85 --- /dev/null +++ b/src/banjo_utils/celery_health/persistent.py @@ -0,0 +1,22 @@ +"""Heartbeat scheduler for celery's built-in ``PersistentScheduler``. + +Use this when you run plain celery beat (no ``django-celery-beat``):: + + celery -A proj beat --scheduler banjo_utils.celery_health.persistent.HeartbeatPersistentScheduler + +``PersistentScheduler`` has a ``max_interval`` of 300s, so a beat liveness +probe for this scheduler must set ``--max-age`` above 300s. Most projects use +the ``DatabaseScheduler`` instead (see +:mod:`banjo_utils.celery_health.database`); this dependency-free subclass is +kept primarily as the test vehicle for the mixin. +""" + +from __future__ import annotations + +from celery.beat import PersistentScheduler + +from banjo_utils.celery_health import HeartbeatSchedulerMixin + + +class HeartbeatPersistentScheduler(HeartbeatSchedulerMixin, PersistentScheduler): + """``PersistentScheduler`` that touches a heartbeat file after each tick.""" diff --git a/src/banjo_utils/celery_health/worker.py b/src/banjo_utils/celery_health/worker.py new file mode 100644 index 0000000..bb7466a --- /dev/null +++ b/src/banjo_utils/celery_health/worker.py @@ -0,0 +1,100 @@ +"""Worker liveness heartbeat via a celery bootstep. + +A celery worker is a non-HTTP process, so its liveness is proven by touching a +heartbeat file on the worker's own :class:`~celery.worker.components.Timer`. +The timer runs in the worker's MainProcess; under the (default) **prefork** +pool, long-running tasks execute in forked child processes and never stall the +timer -- so the heartbeat keeps advancing while real work is in flight. + +.. caution:: + Under the ``gevent``/``eventlet`` pools the timer shares the single event + loop with tasks, so a CPU-bound or blocking task *can* stall the heartbeat + and trigger a liveness restart. The bootstep is intended for prefork. + +Wire it up in your ``celery.py`` right after creating the app:: + + from banjo_utils.celery_health.worker import setup_worker_heartbeat + + app = Celery("proj") + setup_worker_heartbeat(app) + +The reader side is :mod:`banjo_utils.celery_probe`, run as a kubelet ``exec`` +liveness probe. +""" + +from __future__ import annotations + +import contextlib +import typing +from pathlib import Path + +from celery import bootsteps +from celery.worker.components import Timer +from typing_extensions import override + +from banjo_utils.celery_health import resolve_writer_heartbeat_file, touch_heartbeat + +if typing.TYPE_CHECKING: + from celery import Celery + +# Worker writes its own file, distinct from beat's. Matches the reader's +# DEFAULT_HEARTBEAT_FILE so a no-config setup still lines up. +DEFAULT_WORKER_HEARTBEAT_FILE = "/tmp/celery_worker_heartbeat" # noqa: S108 + +# Touch every 30s. The reader's 120s default --max-age gives a 4x margin +# (up to 3 missed touches tolerated). Chatty 1s touches are unnecessary for +# liveness; ~2min wedge detection is fine. +WRITE_INTERVAL = 30.0 + + +class WorkerHeartbeatStep(bootsteps.StartStopStep): + """Bootstep that periodically touches the worker heartbeat file. + + Requires the worker ``Timer`` so ``call_repeatedly`` is available when the + step starts. The heartbeat path is resolved from + ``BANJO_CELERY_HEARTBEAT_FILE`` (set by the chart), falling back to + :data:`DEFAULT_WORKER_HEARTBEAT_FILE`. + """ + + requires = (Timer,) + + def __init__(self, worker: typing.Any, **kwargs: typing.Any) -> None: + self.tref: typing.Any = None + self.heartbeat_file = resolve_writer_heartbeat_file(DEFAULT_WORKER_HEARTBEAT_FILE) + super().__init__(worker, **kwargs) + + @override + def start(self, parent: typing.Any) -> None: + # Touch once up front so liveness passes before the first interval, then + # keep refreshing on the worker's timer (runs in MainProcess; prefork + # children never stall it). + touch_heartbeat(self.heartbeat_file) + self.tref = parent.timer.call_repeatedly( + WRITE_INTERVAL, + touch_heartbeat, + (self.heartbeat_file,), + ) + + @override + def stop(self, parent: typing.Any) -> None: + if self.tref is not None: + self.tref.cancel() + self.tref = None + # Remove the heartbeat on a clean stop so a not-yet-restarted pod looks + # dead to the probe rather than falsely alive on a stale file. + with contextlib.suppress(FileNotFoundError): + Path(self.heartbeat_file).unlink() + + +def setup_worker_heartbeat(app: Celery) -> type[WorkerHeartbeatStep]: + """Register the worker heartbeat bootstep on ``app``. + + One-liner to call right after creating your Celery app. Returns the step + class for reference. Idempotent -- the worker blueprint stores steps in a + set, so calling twice registers it once. + """ + # ``app.steps`` is a defaultdict(set) at runtime but celery types it loosely + # (class-level ``steps = None``); cast so the subscript type-checks. + worker_steps = typing.cast("typing.Any", app.steps) + worker_steps["worker"].add(WorkerHeartbeatStep) + return WorkerHeartbeatStep diff --git a/src/banjo_utils/celery_probe.py b/src/banjo_utils/celery_probe.py new file mode 100644 index 0000000..9cf4866 --- /dev/null +++ b/src/banjo_utils/celery_probe.py @@ -0,0 +1,100 @@ +"""Pod-local liveness probe reader for celery worker & beat heartbeats. + +This module is intentionally **stdlib-only** -- it must NOT import ``celery`` +or ``django``. It is ``exec``'d by the kubelet as a liveness probe inside the +same container as the heartbeat *writer* (the worker bootstep or the beat +scheduler mixin), so it has to start fast and work even in stripped images +where celery may not be importable in the probe's environment. + +Design mirrors ``banjo_utils.health``: pod-local, cheap, dependency-free, and +it only answers "is this process wedged?". A worker/beat process touches a +heartbeat file on a fixed interval; this reader ``stat()``s that file and +fails (exit 1) when the file is missing or its mtime is older than +``--max-age``. It deliberately performs **no** broker round-trip (the reason +``celery inspect ping`` was rejected: false-positive restarts under load and +broker CPU cost -- see https://github.com/celery/celery/issues/4079). + +Usage (as installed console script):: + + banjo-celery-probe [--heartbeat-file PATH] [--max-age SECONDS] + +Path resolution order: ``--heartbeat-file`` CLI arg, then the +``BANJO_CELERY_HEARTBEAT_FILE`` env var (inherited from the writer's +container), then the code default. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from pathlib import Path + +# Name of the env var both writer and reader agree on. The reader inherits it +# from the container the writer runs in, so a chart can set it once and the +# mount path / writer / reader never drift. +HEARTBEAT_FILE_ENV = "BANJO_CELERY_HEARTBEAT_FILE" + +# Code default used only when neither the CLI arg nor the env var is set. The +# worker writer uses this same path by default; beat writes a different file, +# so beat probes must pass --heartbeat-file or set BANJO_CELERY_HEARTBEAT_FILE. +DEFAULT_HEARTBEAT_FILE = "/tmp/celery_worker_heartbeat" # noqa: S108 + +# Staleness budget in seconds. 120s == 4x the worker's 30s write interval, so +# up to 3 missed touches are tolerated before the probe flags the process. +# The real wedge budget is k8s ``failureThreshold * periodSeconds`` (set in the +# chart); this is just the per-probe staleness check. +DEFAULT_MAX_AGE = 120 + + +def is_fresh(path: str, max_age: float, now: float | None = None) -> bool: + """Return ``True`` if ``path`` exists and was touched within ``max_age`` seconds. + + Returns ``False`` if the file is missing (writer never started / crashed) + or stale (writer wedged). + """ + try: + mtime = Path(path).stat().st_mtime + except OSError: + return False + if now is None: + now = time.time() + return (now - mtime) <= max_age + + +def resolve_heartbeat_file(cli_value: str | None) -> str: + """Resolve the heartbeat path: CLI arg, then env var, then code default.""" + if cli_value: + return cli_value + return os.environ.get(HEARTBEAT_FILE_ENV) or DEFAULT_HEARTBEAT_FILE + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="banjo-celery-probe", + description="Liveness probe for a celery worker/beat heartbeat file.", + ) + parser.add_argument( + "--heartbeat-file", + default=None, + help=(f"Path to the heartbeat file. Defaults to the {HEARTBEAT_FILE_ENV} env var, then {DEFAULT_HEARTBEAT_FILE}."), + ) + parser.add_argument( + "--max-age", + type=float, + default=DEFAULT_MAX_AGE, + help=f"Maximum heartbeat age in seconds before failing. Default {DEFAULT_MAX_AGE}.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Entry point. Returns 0 when the heartbeat is fresh, 1 otherwise.""" + args = _build_parser().parse_args(argv) + path = resolve_heartbeat_file(args.heartbeat_file) + return 0 if is_fresh(path, args.max_age) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/banjo_utils/health.py b/src/banjo_utils/health.py new file mode 100644 index 0000000..dad7f6d --- /dev/null +++ b/src/banjo_utils/health.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import typing + +from django.conf import settings +from django.http import HttpResponse + +if typing.TYPE_CHECKING: + from collections.abc import Callable + + from django.http import HttpRequest + + # A Sentry ``traces_sampler`` receives the sampling context dict and returns + # a sample rate. Typed loosely here to avoid a hard dependency on sentry-sdk. + SamplingContext = dict[str, typing.Any] + TracesSampler = Callable[[SamplingContext], float] + +# Default URLs for the Kubernetes probe endpoints. Override in Django +# settings via BANJO_HEALTH_PROBE_LIVE_URL / BANJO_HEALTH_PROBE_READY_URL. +DEFAULT_LIVE_URL = "/healthz/live/" +DEFAULT_READY_URL = "/healthz/ready/" + + +def get_health_probe_paths() -> tuple[str, str]: + """Return the configured ``(live, ready)`` probe paths. + + Trailing slashes are stripped so callers can compare against an incoming + path without worrying about the slash. + """ + live: str = getattr(settings, "BANJO_HEALTH_PROBE_LIVE_URL", DEFAULT_LIVE_URL) + ready: str = getattr(settings, "BANJO_HEALTH_PROBE_READY_URL", DEFAULT_READY_URL) + return live.rstrip("/"), ready.rstrip("/") + + +def is_health_probe_path(path: str) -> bool: + """Return ``True`` if ``path`` targets a probe endpoint. + + Trailing slashes are ignored, so both ``/healthz/live`` and + ``/healthz/live/`` match. + + Use this anywhere you want to treat probe traffic specially -- e.g. inside + a Sentry ``traces_sampler`` or ``before_send`` hook, a logging filter, or + an access-log skip list:: + + from banjo_utils.health import extract_sentry_request_health_probe_path, is_health_probe_path + + def traces_sampler(sampling_context): + if is_health_probe_path(extract_sentry_request_health_probe_path(sampling_context)): + return 0.0 + return 0.1 + """ + return path.rstrip("/") in get_health_probe_paths() + + +def extract_sentry_request_health_probe_path(sampling_context: SamplingContext) -> str: + """Pull the request path out of a Sentry sampling context (WSGI or ASGI). + + Returns an empty string if the context carries no request (e.g. a + background task transaction). Handy for feeding ``is_health_probe_path`` + from your own ``traces_sampler``. + """ + environ = sampling_context.get("wsgi_environ") + if environ: + return environ.get("PATH_INFO", "") + scope = sampling_context.get("asgi_scope") + if scope: + return scope.get("path", "") + return "" + + +def make_sentry_traces_sampler_with_health_probe_ignore(default: float | TracesSampler) -> TracesSampler: + """Build a Sentry ``traces_sampler`` that never samples probe requests. + + Sentry instruments requests at the WSGI/ASGI layer -- outside Django's + middleware stack -- so probe hits create transactions (and consume quota) + even though ``HealthProbeMiddleware`` short-circuits them. This sampler + returns ``0.0`` for probe paths and delegates everything else to + ``default``. + + ``default`` may be a plain sample rate or another ``traces_sampler`` + callable to defer to:: + + import sentry_sdk + from banjo_utils.health import make_sentry_traces_sampler_with_health_probe_ignore + + # Use directly with init... + sentry_sdk.init(traces_sampler=make_sentry_traces_sampler_with_health_probe_ignore(0.1)) + + # ...or wrap your own sampler. + sentry_sdk.init(traces_sampler=make_sentry_traces_sampler_with_health_probe_ignore(my_sampler)) + """ + + def traces_sampler(sampling_context: SamplingContext) -> float: + path = extract_sentry_request_health_probe_path(sampling_context) + if path and is_health_probe_path(path): + return 0.0 + if callable(default): + return default(sampling_context) + return default + + return traces_sampler + + +class HealthProbeMiddleware: + """Serve Kubernetes liveness/readiness probe endpoints. + + Register this as the *first* entry in ``MIDDLEWARE`` so probe requests + short-circuit before any other middleware runs. This keeps the probes + cheap and dependency-free: they report whether the WSGI process can + answer HTTP at all, and never touch the database, cache or session. + + Because the response is returned before ``request.get_host()`` is called, + probe requests bypass the ``ALLOWED_HOSTS`` check -- so the kubelet may hit + the pod by IP without any extra probe ``httpHeaders`` configuration. + + Liveness vs readiness: + - ``/healthz/live/`` answers "is this process wedged?" -- a failure means + the kubelet should restart the pod. + - ``/healthz/ready/`` answers "can this pod serve traffic?" -- a failure + means the pod is pulled from the Service endpoints. + + Both intentionally check only pod-local health (i.e. "the process is up"). + Shared dependencies (DB, Redis, S3) are deliberately *not* checked here: a + shared-dependency outage would fail every pod's readiness simultaneously, + draining the Service to zero endpoints and stampeding the dependency on + recovery. Surface those failures as request errors instead, and use an + external monitoring tool for deep health checks. + """ + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + if is_health_probe_path(request.path): + return HttpResponse("ok", content_type="text/plain") + return self.get_response(request) diff --git a/src/banjo_utils/management/commands/wait_for_resources.py b/src/banjo_utils/management/commands/wait_for_resources.py index 296f9ea..39bf726 100644 --- a/src/banjo_utils/management/commands/wait_for_resources.py +++ b/src/banjo_utils/management/commands/wait_for_resources.py @@ -8,7 +8,6 @@ from django.core.management.base import BaseCommand, CommandParser from django.db import connections from django.db.utils import OperationalError -from redis.exceptions import ConnectionError as RedisConnectionError from typing_extensions import override from banjo_utils.utils.retry import RetryHelper @@ -38,21 +37,69 @@ def wait_for_db(self) -> None: retry_helper.wait() self.stdout.write(self.style.SUCCESS(f"DB is available after {retry_helper.total_time()} seconds")) - def wait_for_redis(self) -> None: - self.stdout.write("Waiting for Redis...") + def wait_for_cache(self) -> None: + # ``--cache`` exercises Django's cache abstraction, not redis directly. + # ``redis`` (redis-py) is not a banjo-utils runtime dependency; a + # redis-backed project supplies it (typically via django-redis). Import + # it lazily so ``--db``/``--celery-broker``/``--minio`` never require it, + # and re-raise with a friendly message if the ``--cache`` path is used + # without it. + try: + from redis.exceptions import ConnectionError as RedisConnectionError # noqa: PLC0415 + except ImportError as e: + raise ImportError( + "wait_for_resources --cache requires the 'redis' package, which is " + "not a banjo-utils runtime dependency. Install a redis client in your " + "project (e.g. django-redis, which brings redis in).", + ) from e + + self.stdout.write("Waiting for Cache...") retry_helper = RetryHelper() while True: try: cache.set("wait-for-it-ping", "pong", timeout=1) - redis_conn = cache.get("wait-for-it-ping") - if redis_conn != "pong": + cache_value = cache.get("wait-for-it-ping") + if cache_value != "pong": raise TypeError break except (RedisConnectionError, TypeError): ... - self.stdout.write(self.style.WARNING(retry_helper.try_again_message("Redis not available"))) + self.stdout.write(self.style.WARNING(retry_helper.try_again_message("Cache not available"))) retry_helper.wait() - self.stdout.write(self.style.SUCCESS(f"Redis is available after {retry_helper.total_time()} seconds")) + self.stdout.write(self.style.SUCCESS(f"Cache is available after {retry_helper.total_time()} seconds")) + + def wait_for_celery_broker(self) -> None: + # ``kombu`` (installed with celery) is not a banjo-utils runtime + # dependency; a celery-backed project supplies it. Import it lazily so + # ``--db``/``--cache``/``--minio`` never require it, and re-raise with a + # friendly message if the ``--celery-broker`` path is used without it. + try: + from kombu import Connection # noqa: PLC0415 + from kombu.exceptions import OperationalError as KombuOperationalError # noqa: PLC0415 + except ImportError as e: + raise ImportError( + "wait_for_resources --celery-broker requires 'kombu' (installed with " + "celery), which is not a banjo-utils runtime dependency. Install celery " + "in your project.", + ) from e + + broker_url = getattr(settings, "CELERY_BROKER_URL", None) + if not broker_url: + self.stdout.write(self.style.WARNING("No CELERY_BROKER_URL provided. Skipping wait")) + return + + self.stdout.write("Waiting for Celery broker...") + retry_helper = RetryHelper() + while True: + try: + with Connection(broker_url, connect_timeout=5) as conn: + conn.ensure_connection(max_retries=1) + break + except KombuOperationalError: + ... + self.stdout.write(self.style.WARNING(retry_helper.try_again_message("Celery broker not available"))) + retry_helper.wait() + self.stdout.write(self.style.SUCCESS(f"Celery broker is available after {retry_helper.total_time()} seconds")) def wait_for_minio(self) -> None: self.stdout.write("Waiting for Minio...") @@ -85,8 +132,13 @@ def add_arguments(self, parser: CommandParser) -> None: help="The maximum time (in seconds) the command is allowed to run before timing out. Default is 10 min.", ) parser.add_argument("--db", action="store_true", help="Wait for DB to be available") - parser.add_argument("--redis", action="store_true", help="Wait for Redis to be available") + parser.add_argument("--cache", action="store_true", help="Wait for the Django cache backend to be available") parser.add_argument("--minio", action="store_true", help="Wait for MinIO (S3) storage to be available") + parser.add_argument( + "--celery-broker", + action="store_true", + help="Wait for the Celery broker (CELERY_BROKER_URL) to be available", + ) @override def handle(self, *_: Any, **kwargs: Any) -> None: @@ -98,8 +150,10 @@ def handle(self, *_: Any, **kwargs: Any) -> None: self.wait_for_db() if kwargs.get("minio"): self.wait_for_minio() - if kwargs.get("redis"): - self.wait_for_redis() + if kwargs.get("cache"): + self.wait_for_cache() + if kwargs.get("celery_broker"): + self.wait_for_celery_broker() except TimeoutException: self.stderr.write(self.style.ERROR("Timed out while waiting for resources.")) finally: diff --git a/tests/conftest.py b/tests/conftest.py index 70a34e0..d648fb3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ def pytest_configure(): "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", + "django_celery_beat", "banjo_utils", ], MIDDLEWARE=[ diff --git a/tests/test_celery_health.py b/tests/test_celery_health.py new file mode 100644 index 0000000..8900cfa --- /dev/null +++ b/tests/test_celery_health.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import builtins +import importlib +import os +import sys +import typing + +import pytest +from celery.beat import PersistentScheduler +from django_celery_beat.schedulers import DatabaseScheduler + +from banjo_utils.celery_health import ( + DEFAULT_BEAT_HEARTBEAT_FILE, + HeartbeatSchedulerMixin, + resolve_writer_heartbeat_file, +) +from banjo_utils.celery_health.database import HeartbeatDatabaseScheduler +from banjo_utils.celery_health.persistent import HeartbeatPersistentScheduler +from banjo_utils.celery_probe import HEARTBEAT_FILE_ENV + +if typing.TYPE_CHECKING: + from pathlib import Path + + +class _FakeBase: + """Stand-in for a celery scheduler base recording tick() calls.""" + + def __init__(self) -> None: + self.tick_calls = 0 + + def tick(self, *args: typing.Any, **kwargs: typing.Any) -> float: + self.tick_calls += 1 + return 5.0 + + +class _RaisingBase: + def tick(self, *args: typing.Any, **kwargs: typing.Any) -> float: + raise RuntimeError("boom") + + +def test_resolve_writer_prefers_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(HEARTBEAT_FILE_ENV, "/from/env") + + assert resolve_writer_heartbeat_file("/default") == "/from/env" + + +def test_resolve_writer_falls_back_to_default(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv(HEARTBEAT_FILE_ENV, raising=False) + + assert resolve_writer_heartbeat_file("/default") == "/default" + + +def test_default_beat_path_is_beat_heartbeat(): + assert DEFAULT_BEAT_HEARTBEAT_FILE == "/tmp/celery_beat_heartbeat" # noqa: S108 + + +def test_mixin_tick_touches_file_and_passes_through_super(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + path = str(tmp_path / "beat_hb") + monkeypatch.setenv(HEARTBEAT_FILE_ENV, path) + + class Sched(HeartbeatSchedulerMixin, _FakeBase): + pass + + sched = Sched() + result = sched.tick() + + assert result == 5.0 # mixin returns whatever super().tick() returned + assert sched.tick_calls == 1 + assert os.path.exists(path) # noqa: PTH110 + + +def test_mixin_does_not_touch_when_super_tick_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # Proves the touch happens *after* super().tick() returns -- a tick that + # never completes must not refresh the heartbeat. + path = str(tmp_path / "beat_hb") + monkeypatch.setenv(HEARTBEAT_FILE_ENV, path) + + class Sched(HeartbeatSchedulerMixin, _RaisingBase): + pass + + with pytest.raises(RuntimeError, match="boom"): + Sched().tick() + + assert not os.path.exists(path) # noqa: PTH110 + + +def test_persistent_scheduler_overrides_tick(): + assert issubclass(HeartbeatPersistentScheduler, PersistentScheduler) + assert issubclass(HeartbeatPersistentScheduler, HeartbeatSchedulerMixin) + # Mixin must sit before the base in the MRO so its tick() wins. + assert HeartbeatPersistentScheduler.tick is HeartbeatSchedulerMixin.tick + + +def test_database_scheduler_overrides_tick(): + assert issubclass(HeartbeatDatabaseScheduler, DatabaseScheduler) + assert HeartbeatDatabaseScheduler.tick is HeartbeatSchedulerMixin.tick + + +def test_database_scheduler_raises_friendly_error_without_dcb(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delitem(sys.modules, "banjo_utils.celery_health.database", raising=False) + real_import = builtins.__import__ + + def fake_import(name: str, *args: typing.Any, **kwargs: typing.Any): + if name.startswith("django_celery_beat"): + raise ImportError("simulated missing dep") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ImportError, match="django-celery-beat"): + importlib.import_module("banjo_utils.celery_health.database") diff --git a/tests/test_celery_probe.py b/tests/test_celery_probe.py new file mode 100644 index 0000000..6a7f989 --- /dev/null +++ b/tests/test_celery_probe.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import os +import typing +from pathlib import Path + +if typing.TYPE_CHECKING: + import pytest + +from banjo_utils.celery_probe import ( + DEFAULT_MAX_AGE, + HEARTBEAT_FILE_ENV, + is_fresh, + main, + resolve_heartbeat_file, +) + + +def _write_heartbeat(path: str, age_seconds: float) -> None: + """Create ``path`` and backdate its mtime by ``age_seconds``.""" + p = Path(path) + p.touch() + mtime = p.stat().st_mtime + os.utime(path, (mtime, mtime - age_seconds)) + + +def test_is_fresh_true_for_recent_file(tmp_path: Path): + path = str(tmp_path / "hb") + _write_heartbeat(path, age_seconds=10) + + assert is_fresh(path, max_age=120) is True + + +def test_is_fresh_false_for_stale_file(tmp_path: Path): + path = str(tmp_path / "hb") + _write_heartbeat(path, age_seconds=300) + + assert is_fresh(path, max_age=120) is False + + +def test_is_fresh_false_for_missing_file(tmp_path: Path): + assert is_fresh(str(tmp_path / "does-not-exist"), max_age=120) is False + + +def test_resolve_prefers_cli_over_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(HEARTBEAT_FILE_ENV, "/from/env") + + assert resolve_heartbeat_file("/from/cli") == "/from/cli" + + +def test_resolve_falls_back_to_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(HEARTBEAT_FILE_ENV, "/from/env") + + assert resolve_heartbeat_file(None) == "/from/env" + + +def test_resolve_falls_back_to_default(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv(HEARTBEAT_FILE_ENV, raising=False) + + assert resolve_heartbeat_file(None).startswith("/") + + +def test_main_exits_zero_for_fresh_file(tmp_path: Path): + path = str(tmp_path / "hb") + _write_heartbeat(path, age_seconds=5) + + assert main(["--heartbeat-file", path, "--max-age", "120"]) == 0 + + +def test_main_exits_one_for_stale_file(tmp_path: Path): + path = str(tmp_path / "hb") + _write_heartbeat(path, age_seconds=300) + + assert main(["--heartbeat-file", path, "--max-age", "120"]) == 1 + + +def test_main_exits_one_for_missing_file(tmp_path: Path): + assert main(["--heartbeat-file", str(tmp_path / "nope")]) == 1 + + +def test_main_reads_path_from_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + path = str(tmp_path / "hb") + _write_heartbeat(path, age_seconds=5) + monkeypatch.setenv(HEARTBEAT_FILE_ENV, path) + + assert main([]) == 0 + + +def test_default_max_age_is_120(tmp_path: Path): + assert DEFAULT_MAX_AGE == 120 + + path = str(tmp_path / "hb") + _write_heartbeat(path, age_seconds=119) + assert main(["--heartbeat-file", path]) == 0 + + _write_heartbeat(path, age_seconds=121) + assert main(["--heartbeat-file", path]) == 1 diff --git a/tests/test_celery_worker_heartbeat.py b/tests/test_celery_worker_heartbeat.py new file mode 100644 index 0000000..9c60522 --- /dev/null +++ b/tests/test_celery_worker_heartbeat.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import os +import typing +from unittest.mock import MagicMock + +if typing.TYPE_CHECKING: + from pathlib import Path + + import pytest + +from celery import Celery +from celery.worker.components import Timer + +from banjo_utils.celery_health.worker import ( + DEFAULT_WORKER_HEARTBEAT_FILE, + WRITE_INTERVAL, + WorkerHeartbeatStep, + setup_worker_heartbeat, +) +from banjo_utils.celery_probe import DEFAULT_HEARTBEAT_FILE, HEARTBEAT_FILE_ENV + + +def test_write_interval_is_30_seconds(): + assert WRITE_INTERVAL == 30.0 + + +def test_worker_default_path_matches_reader_default(): + # The worker writer and the reader share a default so a no-config setup + # (no env, no --heartbeat-file) still lines up. + assert DEFAULT_WORKER_HEARTBEAT_FILE == "/tmp/celery_worker_heartbeat" # noqa: S108 + assert DEFAULT_WORKER_HEARTBEAT_FILE == DEFAULT_HEARTBEAT_FILE + + +def test_setup_registers_step_on_worker_blueprint(): + app = Celery("test") + setup_worker_heartbeat(app) + + assert WorkerHeartbeatStep in typing.cast("typing.Any", app.steps)["worker"] + + +def test_step_requires_timer(): + assert Timer in WorkerHeartbeatStep.requires + + +def test_start_touches_immediately_and_schedules_repeat(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + path = str(tmp_path / "worker_hb") + monkeypatch.setenv(HEARTBEAT_FILE_ENV, path) + + worker = MagicMock() + step = WorkerHeartbeatStep(worker) + step.start(worker) + + # Touched immediately so liveness passes before the first interval elapses. + assert os.path.exists(path) # noqa: PTH110 + # And a repeating touch is scheduled on the worker's timer. + worker.timer.call_repeatedly.assert_called_once() + interval = worker.timer.call_repeatedly.call_args.args[0] + assert interval == WRITE_INTERVAL + + +def test_stop_cancels_timer_and_unlinks_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + path = str(tmp_path / "worker_hb") + monkeypatch.setenv(HEARTBEAT_FILE_ENV, path) + + worker = MagicMock() + tref = MagicMock() + worker.timer.call_repeatedly.return_value = tref + + step = WorkerHeartbeatStep(worker) + step.start(worker) + assert os.path.exists(path) # noqa: PTH110 + + step.stop(worker) + + tref.cancel.assert_called_once() + assert not os.path.exists(path) # noqa: PTH110 + + +def test_stop_is_safe_when_file_already_gone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + path = str(tmp_path / "worker_hb") + monkeypatch.setenv(HEARTBEAT_FILE_ENV, path) + + worker = MagicMock() + step = WorkerHeartbeatStep(worker) + step.start(worker) + os.remove(path) # noqa: PTH107 + + # Should not raise even though the file is already gone. + step.stop(worker) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..e388e71 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import typing + +from django.http import HttpResponse +from django.test import RequestFactory + +from banjo_utils.health import ( + DEFAULT_LIVE_URL, + DEFAULT_READY_URL, + HealthProbeMiddleware, + extract_sentry_request_health_probe_path, + get_health_probe_paths, + is_health_probe_path, + make_sentry_traces_sampler_with_health_probe_ignore, +) + +if typing.TYPE_CHECKING: + from django.http import HttpRequest + from pytest_django.fixtures import SettingsWrapper + +_SENTINEL = "downstream-was-called" + + +def _build_middleware() -> HealthProbeMiddleware: + def get_response(request: HttpRequest) -> HttpResponse: + return HttpResponse(_SENTINEL) + + return HealthProbeMiddleware(get_response) + + +def test_live_endpoint_returns_ok(): + middleware = _build_middleware() + response = middleware(RequestFactory().get(DEFAULT_LIVE_URL)) + + assert response.status_code == 200 + assert response.content == b"ok" + assert response["Content-Type"] == "text/plain" + + +def test_ready_endpoint_returns_ok(): + middleware = _build_middleware() + response = middleware(RequestFactory().get(DEFAULT_READY_URL)) + + assert response.status_code == 200 + assert response.content == b"ok" + + +def test_probe_paths_short_circuit_downstream(): + middleware = _build_middleware() + response = middleware(RequestFactory().get(DEFAULT_LIVE_URL)) + + assert response.content != _SENTINEL.encode() + + +def test_non_probe_path_falls_through(): + middleware = _build_middleware() + response = middleware(RequestFactory().get("/some/other/path/")) + + assert response.content == _SENTINEL.encode() + + +def test_trailing_slash_is_tolerated(): + middleware = _build_middleware() + + with_slash = middleware(RequestFactory().get("/healthz/live/")) + without_slash = middleware(RequestFactory().get("/healthz/live")) + + assert with_slash.content == b"ok" + assert without_slash.content == b"ok" + + +def test_urls_are_configurable(settings: SettingsWrapper): + settings.BANJO_HEALTH_PROBE_LIVE_URL = "/livez" + settings.BANJO_HEALTH_PROBE_READY_URL = "/readyz" + middleware = _build_middleware() + + assert middleware(RequestFactory().get("/livez")).content == b"ok" + assert middleware(RequestFactory().get("/readyz")).content == b"ok" + # The defaults no longer match once overridden. + assert middleware(RequestFactory().get(DEFAULT_LIVE_URL)).content == _SENTINEL.encode() + + +def test_disallowed_host_is_not_checked(): + # Probe requests arrive with the pod IP as Host; the middleware must + # answer without triggering the ALLOWED_HOSTS check. + middleware = _build_middleware() + request = RequestFactory().get(DEFAULT_LIVE_URL, HTTP_HOST="10.1.2.3") + response = middleware(request) + + assert response.status_code == 200 + assert response.content == b"ok" + + +def test_get_health_probe_paths_defaults(): + assert get_health_probe_paths() == ("/healthz/live", "/healthz/ready") + + +def test_get_health_probe_paths_respects_overrides(settings: SettingsWrapper): + settings.BANJO_HEALTH_PROBE_LIVE_URL = "/livez/" + settings.BANJO_HEALTH_PROBE_READY_URL = "/readyz/" + + assert get_health_probe_paths() == ("/livez", "/readyz") + + +def test_is_health_probe_path(): + assert is_health_probe_path("/healthz/live/") + assert is_health_probe_path("/healthz/live") + assert is_health_probe_path("/healthz/ready/") + assert not is_health_probe_path("/") + assert not is_health_probe_path("/api/v1/users/") + + +def test_extract_sentry_request_health_probe_path(): + assert extract_sentry_request_health_probe_path({"wsgi_environ": {"PATH_INFO": "/healthz/live/"}}) == "/healthz/live/" + assert extract_sentry_request_health_probe_path({"asgi_scope": {"path": "/healthz/ready"}}) == "/healthz/ready" + assert extract_sentry_request_health_probe_path({}) == "" + + +def test_sentry_sampler_drops_probe_requests_wsgi(): + sampler = make_sentry_traces_sampler_with_health_probe_ignore(0.25) + + probe = {"wsgi_environ": {"PATH_INFO": "/healthz/live/"}} + normal = {"wsgi_environ": {"PATH_INFO": "/api/v1/users/"}} + + assert sampler(probe) == 0.0 + assert sampler(normal) == 0.25 + + +def test_sentry_sampler_drops_probe_requests_asgi(): + sampler = make_sentry_traces_sampler_with_health_probe_ignore(0.25) + + probe = {"asgi_scope": {"path": "/healthz/ready"}} + + assert sampler(probe) == 0.0 + + +def test_sentry_sampler_delegates_to_callable_default(): + def inner(sampling_context: dict[str, object]) -> float: + return 0.5 if sampling_context.get("flagged") else 0.1 + + sampler = make_sentry_traces_sampler_with_health_probe_ignore(inner) + + assert sampler({"wsgi_environ": {"PATH_INFO": "/healthz/live/"}}) == 0.0 + assert sampler({"wsgi_environ": {"PATH_INFO": "/x/"}}) == 0.1 + assert sampler({"wsgi_environ": {"PATH_INFO": "/x/"}, "flagged": True}) == 0.5 + + +def test_sentry_sampler_with_empty_context_uses_default(): + sampler = make_sentry_traces_sampler_with_health_probe_ignore(0.3) + + assert sampler({}) == 0.3 diff --git a/tests/test_wait_for_resources.py b/tests/test_wait_for_resources.py index ad46eaa..079b7fb 100644 --- a/tests/test_wait_for_resources.py +++ b/tests/test_wait_for_resources.py @@ -11,6 +11,7 @@ from django.core.management import call_command from django.db.utils import OperationalError +from kombu.exceptions import OperationalError as KombuOperationalError from banjo_utils.management.commands.wait_for_resources import ( TimeoutException, @@ -37,13 +38,41 @@ def test_wait_for_db_retries_then_succeeds(mock_sleep: MagicMock, mock_connectio @patch("banjo_utils.management.commands.wait_for_resources.cache") @patch("time.sleep", return_value=None) -def test_wait_for_redis_success(mock_sleep: MagicMock, mock_cache: MagicMock): +def test_wait_for_cache_success(mock_sleep: MagicMock, mock_cache: MagicMock): mock_cache.set.return_value = None mock_cache.get.return_value = "pong" - out, _ = run_command("--redis") + out, _ = run_command("--cache") - assert "Redis is available" in out + assert "Cache is available" in out + mock_sleep.assert_not_called() + + +@patch("kombu.Connection") +@patch("time.sleep", return_value=None) +def test_wait_for_celery_broker_retries_then_succeeds( + mock_sleep: MagicMock, + mock_connection: MagicMock, + settings: LazySettings, +): + settings.CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//" + + conn = mock_connection.return_value.__enter__.return_value + conn.ensure_connection.side_effect = [KombuOperationalError, None] + + out, _ = run_command("--celery-broker") + + assert "Celery broker is available" in out + assert mock_sleep.called + + +@patch("time.sleep", return_value=None) +def test_celery_broker_skipped_when_no_broker_url(mock_sleep: MagicMock, settings: LazySettings): + settings.CELERY_BROKER_URL = None + + out, _ = run_command("--celery-broker") + + assert "Skipping wait" in out mock_sleep.assert_not_called() @@ -94,7 +123,7 @@ def test_multiple_flags(mock_sleep: MagicMock, mock_cache: MagicMock, mock_conne mock_cache.set.return_value = None mock_cache.get.return_value = "pong" - out, _ = run_command("--db", "--redis") + out, _ = run_command("--db", "--cache") assert "DB is available" in out - assert "Redis is available" in out + assert "Cache is available" in out diff --git a/uv.lock b/uv.lock index d85e0fc..7cf521b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.8, <4" resolution-markers = [ "python_full_version >= '3.12'", @@ -8,6 +8,18 @@ resolution-markers = [ "python_full_version < '3.9'", ] +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + [[package]] name = "anyio" version = "4.5.2" @@ -21,9 +33,9 @@ dependencies = [ { name = "sniffio", marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293 } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766 }, + { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" }, ] [[package]] @@ -40,9 +52,9 @@ dependencies = [ { name = "idna", marker = "python_full_version >= '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] [[package]] @@ -55,9 +67,9 @@ resolution-markers = [ dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, ] [[package]] @@ -72,31 +84,36 @@ resolution-markers = [ dependencies = [ { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550 } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345 }, + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] [[package]] name = "async-timeout" version = "5.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "backports-zoneinfo" version = "0.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ad/85/475e514c3140937cf435954f78dedea1861aeab7662d11de232bdaa90655/backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2", size = 74098 } +sdist = { url = "https://files.pythonhosted.org/packages/ad/85/475e514c3140937cf435954f78dedea1861aeab7662d11de232bdaa90655/backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2", size = 74098, upload-time = "2020-06-23T13:51:22.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/6d/eca004eeadcbf8bd64cc96feb9e355536147f0577420b44d80c7cac70767/backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987", size = 35816 }, - { url = "https://files.pythonhosted.org/packages/c1/8f/9b1b920a6a95652463143943fa3b8c000cb0b932ab463764a6f2a2416560/backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1", size = 72147 }, - { url = "https://files.pythonhosted.org/packages/1a/ab/3e941e3fcf1b7d3ab3d0233194d99d6a0ed6b24f8f956fc81e47edc8c079/backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9", size = 74033 }, - { url = "https://files.pythonhosted.org/packages/c0/34/5fdb0a3a28841d215c255be8fc60b8666257bb6632193c86fd04b63d4a31/backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328", size = 36803 }, - { url = "https://files.pythonhosted.org/packages/78/cc/e27fd6493bbce8dbea7e6c1bc861fe3d3bc22c4f7c81f4c3befb8ff5bfaf/backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6", size = 38967 }, + { url = "https://files.pythonhosted.org/packages/4a/6d/eca004eeadcbf8bd64cc96feb9e355536147f0577420b44d80c7cac70767/backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987", size = 35816, upload-time = "2020-06-23T13:51:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9b1b920a6a95652463143943fa3b8c000cb0b932ab463764a6f2a2416560/backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1", size = 72147, upload-time = "2020-06-23T13:51:17.562Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/3e941e3fcf1b7d3ab3d0233194d99d6a0ed6b24f8f956fc81e47edc8c079/backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9", size = 74033, upload-time = "2020-06-23T13:51:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/c0/34/5fdb0a3a28841d215c255be8fc60b8666257bb6632193c86fd04b63d4a31/backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328", size = 36803, upload-time = "2020-06-23T13:51:07.517Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/e27fd6493bbce8dbea7e6c1bc861fe3d3bc22c4f7c81f4c3befb8ff5bfaf/backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6", size = 38967, upload-time = "2020-06-23T13:51:13.735Z" }, +] + +[package.optional-dependencies] +tzdata = [ + { name = "tzdata", marker = "python_full_version < '3.9'" }, ] [[package]] @@ -107,13 +124,16 @@ dependencies = [ { name = "django", version = "4.2.29", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "django-redis" }, { name = "httpx" }, ] [package.dev-dependencies] dev = [ + { name = "celery", version = "5.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "celery", version = "5.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "colorlog" }, + { name = "django-celery-beat" }, + { name = "django-redis" }, { name = "django-stubs", version = "5.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "django-stubs", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "types-factory-boy" }, @@ -131,13 +151,15 @@ test = [ [package.metadata] requires-dist = [ { name = "django", specifier = ">=3.2" }, - { name = "django-redis", specifier = ">=5.3.0,<6" }, { name = "httpx", specifier = ">=0.28.1" }, ] [package.metadata.requires-dev] dev = [ + { name = "celery" }, { name = "colorlog" }, + { name = "django-celery-beat" }, + { name = "django-redis" }, { name = "django-stubs" }, { name = "types-factory-boy", specifier = ">=0.4.1" }, ] @@ -149,22 +171,154 @@ test = [ { name = "pytest-profiling" }, ] +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "celery" +version = "5.5.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "backports-zoneinfo", extra = ["tzdata"], marker = "python_full_version < '3.9'" }, + { name = "billiard", marker = "python_full_version < '3.9'" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "click-didyoumean", marker = "python_full_version < '3.9'" }, + { name = "click-plugins", marker = "python_full_version < '3.9'" }, + { name = "click-repl", marker = "python_full_version < '3.9'" }, + { name = "kombu", version = "5.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "python-dateutil", marker = "python_full_version < '3.9'" }, + { name = "vine", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, +] + +[[package]] +name = "celery" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version >= '3.10' and python_full_version < '3.12'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "billiard", marker = "python_full_version >= '3.9'" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "click", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click-didyoumean", marker = "python_full_version >= '3.9'" }, + { name = "click-plugins", marker = "python_full_version >= '3.9'" }, + { name = "click-repl", marker = "python_full_version >= '3.9'" }, + { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "kombu", version = "5.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.9'" }, + { name = "tzlocal", version = "5.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "tzlocal", version = "5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "vine", marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version < '3.9'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version >= '3.10' and python_full_version < '3.12'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, ] [[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 } +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 }, + { 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]] @@ -174,9 +328,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743 }, + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + +[[package]] +name = "cron-descriptor" +version = "1.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" }, ] [[package]] @@ -194,9 +357,9 @@ dependencies = [ { name = "sqlparse", marker = "python_full_version < '3.10'" }, { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/7306757cf2ac016d718d8a5dbae66de630addaa73dca2c341fc388458e71/django-4.2.29.tar.gz", hash = "sha256:86d91bc8086569c8d08f9c55888b583a921ac1f95ed3bdc7d5659d4709542014", size = 10438980 } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/7306757cf2ac016d718d8a5dbae66de630addaa73dca2c341fc388458e71/django-4.2.29.tar.gz", hash = "sha256:86d91bc8086569c8d08f9c55888b583a921ac1f95ed3bdc7d5659d4709542014", size = 10438980, upload-time = "2026-03-03T13:56:42.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/2b/bd0a1d1846d5580e9f209b9e0128b4859e381ec1b39d6d175c61294bb530/django-4.2.29-py3-none-any.whl", hash = "sha256:074d7c4d2808050e528388bda442bd491f06def4df4fe863f27066851bba010c", size = 7996481 }, + { url = "https://files.pythonhosted.org/packages/ae/2b/bd0a1d1846d5580e9f209b9e0128b4859e381ec1b39d6d175c61294bb530/django-4.2.29-py3-none-any.whl", hash = "sha256:074d7c4d2808050e528388bda442bd491f06def4df4fe863f27066851bba010c", size = 7996481, upload-time = "2026-03-03T13:56:36.495Z" }, ] [[package]] @@ -211,9 +374,9 @@ dependencies = [ { name = "sqlparse", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, { name = "tzdata", marker = "python_full_version >= '3.10' and python_full_version < '3.12' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245 }, + { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" }, ] [[package]] @@ -228,9 +391,30 @@ dependencies = [ { name = "sqlparse", marker = "python_full_version >= '3.12'" }, { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701 } +sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527 }, + { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, +] + +[[package]] +name = "django-celery-beat" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-zoneinfo", marker = "python_full_version < '3.9'" }, + { name = "celery", version = "5.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "celery", version = "5.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "cron-descriptor" }, + { name = "django", version = "4.2.29", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-timezone-field" }, + { name = "python-crontab" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/45/fc97bc1d9af8e7dc07f1e37044d9551a30e6793249864cef802341e2e3a8/django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c", size = 177667, upload-time = "2026-02-28T16:45:34.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/ae/9befa7ae37f5e5c41be636a254fcf47ff30dd5c88bd115070e252f6b9162/django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157", size = 105013, upload-time = "2026-02-28T16:45:32.822Z" }, ] [[package]] @@ -245,9 +429,9 @@ dependencies = [ { name = "redis", version = "7.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "redis", version = "7.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/9d/2272742fdd9d0a9f0b28cd995b0539430c9467a2192e4de2cea9ea6ad38c/django-redis-5.4.0.tar.gz", hash = "sha256:6a02abaa34b0fea8bf9b707d2c363ab6adc7409950b2db93602e6cb292818c42", size = 52567 } +sdist = { url = "https://files.pythonhosted.org/packages/83/9d/2272742fdd9d0a9f0b28cd995b0539430c9467a2192e4de2cea9ea6ad38c/django-redis-5.4.0.tar.gz", hash = "sha256:6a02abaa34b0fea8bf9b707d2c363ab6adc7409950b2db93602e6cb292818c42", size = 52567, upload-time = "2023-10-01T20:22:01.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/f1/63caad7c9222c26a62082f4f777de26389233b7574629996098bf6d25a4d/django_redis-5.4.0-py3-none-any.whl", hash = "sha256:ebc88df7da810732e2af9987f7f426c96204bf89319df4c6da6ca9a2942edd5b", size = 31119 }, + { url = "https://files.pythonhosted.org/packages/b7/f1/63caad7c9222c26a62082f4f777de26389233b7574629996098bf6d25a4d/django_redis-5.4.0-py3-none-any.whl", hash = "sha256:ebc88df7da810732e2af9987f7f426c96204bf89319df4c6da6ca9a2942edd5b", size = 31119, upload-time = "2023-10-01T20:21:33.009Z" }, ] [[package]] @@ -269,9 +453,9 @@ dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/48/e733ceff94ed3c4ccba4c2f0708739974bbcdbcfb69efefb87b10780937f/django_stubs-5.1.3.tar.gz", hash = "sha256:8c230bc5bebee6da282ba8a27ad1503c84a0c4cd2f46e63d149e76d2a63e639a", size = 267390 } +sdist = { url = "https://files.pythonhosted.org/packages/dd/48/e733ceff94ed3c4ccba4c2f0708739974bbcdbcfb69efefb87b10780937f/django_stubs-5.1.3.tar.gz", hash = "sha256:8c230bc5bebee6da282ba8a27ad1503c84a0c4cd2f46e63d149e76d2a63e639a", size = 267390, upload-time = "2025-02-07T09:56:59.773Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/94/3551a181faf44a63a4ef1ab8e0eb7f27f6af168c2f719ea482e54b39d237/django_stubs-5.1.3-py3-none-any.whl", hash = "sha256:716758ced158b439213062e52de6df3cff7c586f9f9ad7ab59210efbea5dfe78", size = 472753 }, + { url = "https://files.pythonhosted.org/packages/74/94/3551a181faf44a63a4ef1ab8e0eb7f27f6af168c2f719ea482e54b39d237/django_stubs-5.1.3-py3-none-any.whl", hash = "sha256:716758ced158b439213062e52de6df3cff7c586f9f9ad7ab59210efbea5dfe78", size = 472753, upload-time = "2025-02-07T09:56:57.291Z" }, ] [[package]] @@ -290,9 +474,9 @@ dependencies = [ { name = "types-pyyaml", version = "6.0.12.20250915", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/9a/78b442ab9254436b3195bffaf4e52d54aa32ac2b47ee1f8481f4614abb01/django_stubs-6.0.1.tar.gz", hash = "sha256:e1ca63634221b57a55e16562b9b6d1849aeee2cabfd0fc026084dbe8aa893366", size = 272906 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/9a/78b442ab9254436b3195bffaf4e52d54aa32ac2b47ee1f8481f4614abb01/django_stubs-6.0.1.tar.gz", hash = "sha256:e1ca63634221b57a55e16562b9b6d1849aeee2cabfd0fc026084dbe8aa893366", size = 272906, upload-time = "2026-03-18T08:10:18.366Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/be/e9280ae94339f49d17af63b2ec508c9f11031196426a04cbdec1b0878d4a/django_stubs-6.0.1-py3-none-any.whl", hash = "sha256:d885044bd0876610f3eb969d6b5ed22f386002a879fdcb369cd8efa0502dbbce", size = 535867 }, + { url = "https://files.pythonhosted.org/packages/43/be/e9280ae94339f49d17af63b2ec508c9f11031196426a04cbdec1b0878d4a/django_stubs-6.0.1-py3-none-any.whl", hash = "sha256:d885044bd0876610f3eb969d6b5ed22f386002a879fdcb369cd8efa0502dbbce", size = 535867, upload-time = "2026-03-18T08:10:16.192Z" }, ] [[package]] @@ -308,9 +492,9 @@ dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/06/7b210e0073c6cb8824bde82afc25f268e8c410a99d3621297f44fa3f6a6c/django_stubs_ext-5.1.3.tar.gz", hash = "sha256:3e60f82337f0d40a362f349bf15539144b96e4ceb4dbd0239be1cd71f6a74ad0", size = 9613 } +sdist = { url = "https://files.pythonhosted.org/packages/9f/06/7b210e0073c6cb8824bde82afc25f268e8c410a99d3621297f44fa3f6a6c/django_stubs_ext-5.1.3.tar.gz", hash = "sha256:3e60f82337f0d40a362f349bf15539144b96e4ceb4dbd0239be1cd71f6a74ad0", size = 9613, upload-time = "2025-02-07T09:56:22.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/52/50125afcf29382b7f9d88a992e44835108dd2f1694d6d17d6d3d6fe06c81/django_stubs_ext-5.1.3-py3-none-any.whl", hash = "sha256:64561fbc53e963cc1eed2c8eb27e18b8e48dcb90771205180fe29fc8a59e55fd", size = 9034 }, + { url = "https://files.pythonhosted.org/packages/cc/52/50125afcf29382b7f9d88a992e44835108dd2f1694d6d17d6d3d6fe06c81/django_stubs_ext-5.1.3-py3-none-any.whl", hash = "sha256:64561fbc53e963cc1eed2c8eb27e18b8e48dcb90771205180fe29fc8a59e55fd", size = 9034, upload-time = "2025-02-07T09:56:19.51Z" }, ] [[package]] @@ -326,9 +510,24 @@ dependencies = [ { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/2f/58034804c7290c183bbb6f97392c98f7188786db68f5b7f66f8abb377ece/django_stubs_ext-6.0.1.tar.gz", hash = "sha256:633b280f89c0cbb7e3ce2f2f842e0acc43d8091378e75f84921d6be675d052dc", size = 6673 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/2f/58034804c7290c183bbb6f97392c98f7188786db68f5b7f66f8abb377ece/django_stubs_ext-6.0.1.tar.gz", hash = "sha256:633b280f89c0cbb7e3ce2f2f842e0acc43d8091378e75f84921d6be675d052dc", size = 6673, upload-time = "2026-03-18T08:08:56.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/67/cf947df79857407f16f6afe8004830544df22ad14edfddfbe17d3ad7d4c8/django_stubs_ext-6.0.1-py3-none-any.whl", hash = "sha256:17415759b9a3f4b4da7998ac3b08c7dc5137f9a019490b918aece1a8a4c2ade4", size = 10169 }, + { url = "https://files.pythonhosted.org/packages/c8/67/cf947df79857407f16f6afe8004830544df22ad14edfddfbe17d3ad7d4c8/django_stubs_ext-6.0.1-py3-none-any.whl", hash = "sha256:17415759b9a3f4b4da7998ac3b08c7dc5137f9a019490b918aece1a8a4c2ade4", size = 10169, upload-time = "2026-03-18T08:08:55.58Z" }, +] + +[[package]] +name = "django-timezone-field" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-zoneinfo", marker = "python_full_version < '3.9'" }, + { name = "django", version = "4.2.29", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, + { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/0b/22654abc2355f3b84e3e5b9d26569639c15d04b96d7186f3a477ec45c1ef/django_timezone_field-7.2.2.tar.gz", hash = "sha256:a004d0b19fe10bf5964cb21a65b36324b16a61879e4711c0dafdf8d6253e8ebc", size = 13158, upload-time = "2026-06-06T05:28:23.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/5f/c8dfb67105c4ef391a3e9d0bcd35b7eae4c4c9d023d612c3999ae1cb32ef/django_timezone_field-7.2.2-py3-none-any.whl", hash = "sha256:30354d0f37462a0b9b5c289e271580a6be9b58dea30e7bf88435372882c8fa7a", size = 13322, upload-time = "2026-06-06T05:28:22.454Z" }, ] [[package]] @@ -339,9 +538,9 @@ dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] @@ -353,9 +552,9 @@ dependencies = [ { name = "faker", version = "37.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "faker", version = "40.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036 }, + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, ] [[package]] @@ -369,9 +568,9 @@ dependencies = [ { name = "python-dateutil", marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/3e/4050154492fda971f17039b5f2619b2dc09a9264d032881bb57d2d99bd63/faker-35.2.2.tar.gz", hash = "sha256:0a79ebe8f0ea803f7bd288d51e2d445b86035a2480e048daee1bffbd4d69b32b", size = 1874955 } +sdist = { url = "https://files.pythonhosted.org/packages/98/3e/4050154492fda971f17039b5f2619b2dc09a9264d032881bb57d2d99bd63/faker-35.2.2.tar.gz", hash = "sha256:0a79ebe8f0ea803f7bd288d51e2d445b86035a2480e048daee1bffbd4d69b32b", size = 1874955, upload-time = "2025-02-10T21:17:35.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/18/f70c67c9d3a71f1749faa027dad3cd626c728df5682458091d73c69ed9a9/Faker-35.2.2-py3-none-any.whl", hash = "sha256:94216ce3d8affdc0a8fd0ea8219c184c346a1dcf07b03f193e52f3116186621e", size = 1917799 }, + { url = "https://files.pythonhosted.org/packages/96/18/f70c67c9d3a71f1749faa027dad3cd626c728df5682458091d73c69ed9a9/Faker-35.2.2-py3-none-any.whl", hash = "sha256:94216ce3d8affdc0a8fd0ea8219c184c346a1dcf07b03f193e52f3116186621e", size = 1917799, upload-time = "2025-02-10T21:17:30.795Z" }, ] [[package]] @@ -384,9 +583,9 @@ resolution-markers = [ dependencies = [ { name = "tzdata", marker = "python_full_version == '3.9.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461 }, + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" }, ] [[package]] @@ -400,27 +599,27 @@ resolution-markers = [ dependencies = [ { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/dc/b68e5378e5a7db0ab776efcdd53b6fe374b29d703e156fd5bb4c5437069e/faker-40.11.0.tar.gz", hash = "sha256:7c419299103b13126bd02ec14bd2b47b946edb5a5eedf305e66a193b25f9a734", size = 1957570 } +sdist = { url = "https://files.pythonhosted.org/packages/94/dc/b68e5378e5a7db0ab776efcdd53b6fe374b29d703e156fd5bb4c5437069e/faker-40.11.0.tar.gz", hash = "sha256:7c419299103b13126bd02ec14bd2b47b946edb5a5eedf305e66a193b25f9a734", size = 1957570, upload-time = "2026-03-13T14:36:11.844Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/fa/a86c6ba66f0308c95b9288b1e3eaccd934b545646f63494a86f1ec2f8c8e/faker-40.11.0-py3-none-any.whl", hash = "sha256:0e9816c950528d2a37d74863f3ef389ea9a3a936cbcde0b11b8499942e25bf90", size = 1989457 }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a86c6ba66f0308c95b9288b1e3eaccd934b545646f63494a86f1ec2f8c8e/faker-40.11.0-py3-none-any.whl", hash = "sha256:0e9816c950528d2a37d74863f3ef389ea9a3a936cbcde0b11b8499942e25bf90", size = 1989457, upload-time = "2026-03-13T14:36:09.792Z" }, ] [[package]] name = "gprof2dot" version = "2025.4.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/fd/cad13fa1f7a463a607176432c4affa33ea162f02f58cc36de1d40d3e6b48/gprof2dot-2025.4.14.tar.gz", hash = "sha256:35743e2d2ca027bf48fa7cba37021aaf4a27beeae1ae8e05a50b55f1f921a6ce", size = 39536 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/fd/cad13fa1f7a463a607176432c4affa33ea162f02f58cc36de1d40d3e6b48/gprof2dot-2025.4.14.tar.gz", hash = "sha256:35743e2d2ca027bf48fa7cba37021aaf4a27beeae1ae8e05a50b55f1f921a6ce", size = 39536, upload-time = "2025-04-14T07:21:45.76Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/ed/89d760cb25279109b89eb52975a7b5479700d3114a2421ce735bfb2e7513/gprof2dot-2025.4.14-py3-none-any.whl", hash = "sha256:0742e4c0b4409a5e8777e739388a11e1ed3750be86895655312ea7c20bd0090e", size = 37555 }, + { url = "https://files.pythonhosted.org/packages/71/ed/89d760cb25279109b89eb52975a7b5479700d3114a2421ce735bfb2e7513/gprof2dot-2025.4.14-py3-none-any.whl", hash = "sha256:0742e4c0b4409a5e8777e739388a11e1ed3750be86895655312ea7c20bd0090e", size = 37555, upload-time = "2025-04-14T07:21:43.319Z" }, ] [[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 } +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 }, + { 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]] @@ -431,9 +630,9 @@ 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 } +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 }, + { 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]] @@ -447,27 +646,27 @@ dependencies = [ { 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 } +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 }, + { 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 = "icdiff" version = "2.0.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/6d/41c8ff13b67c30ceb9325398ff6bb9fc4b77208c6c1d79db8d5913840bc6/icdiff-2.0.10.tar.gz", hash = "sha256:75a3de5c9af35ab45fb0504df59770c514a12c0d2b2c99e5f9c5c2429957e133", size = 16378 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/6d/41c8ff13b67c30ceb9325398ff6bb9fc4b77208c6c1d79db8d5913840bc6/icdiff-2.0.10.tar.gz", hash = "sha256:75a3de5c9af35ab45fb0504df59770c514a12c0d2b2c99e5f9c5c2429957e133", size = 16378, upload-time = "2026-02-08T15:03:26.76Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/4b/bb94514476624bfff977599e5ef3ac9aea603abb693639ed305b22ed1b35/icdiff-2.0.10-py3-none-any.whl", hash = "sha256:fd8ce592d94261c435e2a410f668a41ec3be4527878f8df01100797021d8edb0", size = 17103 }, + { url = "https://files.pythonhosted.org/packages/85/4b/bb94514476624bfff977599e5ef3ac9aea603abb693639ed305b22ed1b35/icdiff-2.0.10-py3-none-any.whl", hash = "sha256:fd8ce592d94261c435e2a410f668a41ec3be4527878f8df01100797021d8edb0", size = 17103, upload-time = "2026-02-08T15:03:25.723Z" }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -478,9 +677,9 @@ resolution-markers = [ "python_full_version == '3.9.*'", "python_full_version < '3.9'", ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -491,18 +690,56 @@ resolution-markers = [ "python_full_version >= '3.12'", "python_full_version >= '3.10' and python_full_version < '3.12'", ] -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +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 }, + { 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 = "kombu" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "amqp", marker = "python_full_version < '3.9'" }, + { name = "backports-zoneinfo", extra = ["tzdata"], marker = "python_full_version < '3.9'" }, + { name = "packaging", marker = "python_full_version < '3.9'" }, + { name = "vine", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version >= '3.10' and python_full_version < '3.12'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "amqp", marker = "python_full_version >= '3.9'" }, + { name = "packaging", marker = "python_full_version >= '3.9'" }, + { name = "tzdata", marker = "python_full_version >= '3.9'" }, + { name = "vine", marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -512,9 +749,9 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] [[package]] @@ -526,27 +763,39 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", "python_full_version == '3.9.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +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 }, + { 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 = "pprintpp" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/1a/7737e7a0774da3c3824d654993cf57adc915cb04660212f03406334d8c0b/pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403", size = 17995 } +sdist = { url = "https://files.pythonhosted.org/packages/06/1a/7737e7a0774da3c3824d654993cf57adc915cb04660212f03406334d8c0b/pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403", size = 17995, upload-time = "2018-07-01T01:42:34.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952 }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] @@ -564,9 +813,9 @@ dependencies = [ { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "tomli", marker = "python_full_version < '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] [[package]] @@ -585,9 +834,9 @@ dependencies = [ { name = "pygments", marker = "python_full_version == '3.9.*'" }, { name = "tomli", marker = "python_full_version == '3.9.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -607,9 +856,9 @@ dependencies = [ { name = "pygments", marker = "python_full_version >= '3.10'" }, { name = "tomli", marker = "python_full_version == '3.10.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] @@ -624,9 +873,9 @@ dependencies = [ { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281 }, + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, ] [[package]] @@ -640,9 +889,9 @@ resolution-markers = [ dependencies = [ { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156 } +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123 }, + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, ] [[package]] @@ -656,9 +905,9 @@ dependencies = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/0c/66e1e2590e98f4428e374a3b6448dc086a908d15b1e24b914539d13b7ac4/pytest-icdiff-0.9.tar.gz", hash = "sha256:13aede616202e57fcc882568b64589002ef85438046f012ac30a8d959dac8b75", size = 7110 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/0c/66e1e2590e98f4428e374a3b6448dc086a908d15b1e24b914539d13b7ac4/pytest-icdiff-0.9.tar.gz", hash = "sha256:13aede616202e57fcc882568b64589002ef85438046f012ac30a8d959dac8b75", size = 7110, upload-time = "2023-12-05T11:18:30.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/e1/cafe1edf7a30be6fa1bbbf43f7af12b34682eadcf19eb6e9f7352062c422/pytest_icdiff-0.9-py3-none-any.whl", hash = "sha256:efee0da3bd1b24ef2d923751c5c547fbb8df0a46795553fba08ef57c3ca03d82", size = 4994 }, + { url = "https://files.pythonhosted.org/packages/e2/e1/cafe1edf7a30be6fa1bbbf43f7af12b34682eadcf19eb6e9f7352062c422/pytest_icdiff-0.9-py3-none-any.whl", hash = "sha256:efee0da3bd1b24ef2d923751c5c547fbb8df0a46795553fba08ef57c3ca03d82", size = 4994, upload-time = "2023-12-05T11:18:28.572Z" }, ] [[package]] @@ -671,9 +920,9 @@ resolution-markers = [ dependencies = [ { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241 } +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923 }, + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, ] [[package]] @@ -689,9 +938,9 @@ dependencies = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] [[package]] @@ -703,9 +952,9 @@ dependencies = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/ba/65091c36e6e18da479d22d860586e3ba3a4237cc92a66e3ddd945e4fe761/pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6", size = 2629 } +sdist = { url = "https://files.pythonhosted.org/packages/e1/ba/65091c36e6e18da479d22d860586e3ba3a4237cc92a66e3ddd945e4fe761/pytest-ordering-0.6.tar.gz", hash = "sha256:561ad653626bb171da78e682f6d39ac33bb13b3e272d406cd555adb6b006bda6", size = 2629, upload-time = "2018-11-14T00:55:26.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/98/adc368fe369465f291ab24e18b9900473786ed1afdf861ba90467eb0767e/pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6", size = 4643 }, + { url = "https://files.pythonhosted.org/packages/ec/98/adc368fe369465f291ab24e18b9900473786ed1afdf861ba90467eb0767e/pytest_ordering-0.6-py3-none-any.whl", hash = "sha256:3f314a178dbeb6777509548727dc69edf22d6d9a2867bf2d310ab85c403380b6", size = 4643, upload-time = "2018-10-25T16:25:18.445Z" }, ] [[package]] @@ -719,9 +968,18 @@ dependencies = [ { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/74/806cafd6f2108d37979ec71e73b2ff7f7db88eabd19d3b79c5d6cc229c36/pytest-profiling-1.8.1.tar.gz", hash = "sha256:3f171fa69d5c82fa9aab76d66abd5f59da69135c37d6ae5bf7557f1b154cb08d", size = 33135 } +sdist = { url = "https://files.pythonhosted.org/packages/44/74/806cafd6f2108d37979ec71e73b2ff7f7db88eabd19d3b79c5d6cc229c36/pytest-profiling-1.8.1.tar.gz", hash = "sha256:3f171fa69d5c82fa9aab76d66abd5f59da69135c37d6ae5bf7557f1b154cb08d", size = 33135, upload-time = "2024-11-29T19:34:13.85Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/ac/c428c66241a144617a8af7a28e2e055e1438d23b949b62ac4b401a69fb79/pytest_profiling-1.8.1-py3-none-any.whl", hash = "sha256:3dd8713a96298b42d83de8f5951df3ada3e61b3e5d2a06956684175529e17aea", size = 9929 }, + { url = "https://files.pythonhosted.org/packages/e3/ac/c428c66241a144617a8af7a28e2e055e1438d23b949b62ac4b401a69fb79/pytest_profiling-1.8.1-py3-none-any.whl", hash = "sha256:3dd8713a96298b42d83de8f5951df3ada3e61b3e5d2a06956684175529e17aea", size = 9929, upload-time = "2024-11-29T19:33:02.111Z" }, +] + +[[package]] +name = "python-crontab" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" }, ] [[package]] @@ -729,11 +987,11 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "python_full_version < '3.9'" }, + { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -746,9 +1004,9 @@ resolution-markers = [ dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/8b/14ef373ffe71c0d2fde93c204eab78472ea13c021d9aee63b0e11bd65896/redis-6.1.1.tar.gz", hash = "sha256:88c689325b5b41cedcbdbdfd4d937ea86cf6dab2222a83e86d8a466e4b3d2600", size = 4629515 } +sdist = { url = "https://files.pythonhosted.org/packages/07/8b/14ef373ffe71c0d2fde93c204eab78472ea13c021d9aee63b0e11bd65896/redis-6.1.1.tar.gz", hash = "sha256:88c689325b5b41cedcbdbdfd4d937ea86cf6dab2222a83e86d8a466e4b3d2600", size = 4629515, upload-time = "2025-06-02T11:44:04.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/cd/29503c609186104c363ef1f38d6e752e7d91ef387fc90aa165e96d69f446/redis-6.1.1-py3-none-any.whl", hash = "sha256:ed44d53d065bbe04ac6d76864e331cfe5c5353f86f6deccc095f8794fd15bb2e", size = 273930 }, + { url = "https://files.pythonhosted.org/packages/c2/cd/29503c609186104c363ef1f38d6e752e7d91ef387fc90aa165e96d69f446/redis-6.1.1-py3-none-any.whl", hash = "sha256:ed44d53d065bbe04ac6d76864e331cfe5c5353f86f6deccc095f8794fd15bb2e", size = 273930, upload-time = "2025-06-02T11:44:02.705Z" }, ] [[package]] @@ -761,9 +1019,9 @@ resolution-markers = [ dependencies = [ { name = "async-timeout", marker = "python_full_version == '3.9.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/8f/f125feec0b958e8d22c8f0b492b30b1991d9499a4315dfde466cf4289edc/redis-7.0.1.tar.gz", hash = "sha256:c949df947dca995dc68fdf5a7863950bf6df24f8d6022394585acc98e81624f1", size = 4755322 } +sdist = { url = "https://files.pythonhosted.org/packages/57/8f/f125feec0b958e8d22c8f0b492b30b1991d9499a4315dfde466cf4289edc/redis-7.0.1.tar.gz", hash = "sha256:c949df947dca995dc68fdf5a7863950bf6df24f8d6022394585acc98e81624f1", size = 4755322, upload-time = "2025-10-27T14:34:00.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/97/9f22a33c475cda519f20aba6babb340fb2f2254a02fb947816960d1e669a/redis-7.0.1-py3-none-any.whl", hash = "sha256:4977af3c7d67f8f0eb8b6fec0dafc9605db9343142f634041fb0235f67c0588a", size = 339938 }, + { url = "https://files.pythonhosted.org/packages/e9/97/9f22a33c475cda519f20aba6babb340fb2f2254a02fb947816960d1e669a/redis-7.0.1-py3-none-any.whl", hash = "sha256:4977af3c7d67f8f0eb8b6fec0dafc9605db9343142f634041fb0235f67c0588a", size = 339938, upload-time = "2025-10-27T14:33:58.553Z" }, ] [[package]] @@ -777,90 +1035,90 @@ resolution-markers = [ dependencies = [ { name = "async-timeout", marker = "python_full_version >= '3.10' and python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/82/4d1a5279f6c1251d3d2a603a798a1137c657de9b12cfc1fba4858232c4d2/redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034", size = 4928081 } +sdist = { url = "https://files.pythonhosted.org/packages/da/82/4d1a5279f6c1251d3d2a603a798a1137c657de9b12cfc1fba4858232c4d2/redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034", size = 4928081, upload-time = "2026-03-06T18:18:16.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/28/84e57fce7819e81ec5aa1bd31c42b89607241f4fb1a3ea5b0d2dbeaea26c/redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364", size = 404379 }, + { url = "https://files.pythonhosted.org/packages/f0/28/84e57fce7819e81ec5aa1bd31c42b89607241f4fb1a3ea5b0d2dbeaea26c/redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364", size = 404379, upload-time = "2026-03-06T18:18:14.583Z" }, ] [[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 } +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 }, + { 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 = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "sqlparse" version = "0.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815 } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138 }, + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] [[package]] name = "tomli" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663 }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469 }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039 }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007 }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875 }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271 }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894 }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053 }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481 }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720 }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014 }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820 }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915 }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038 }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245 }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335 }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962 }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396 }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530 }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227 }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748 }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725 }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901 }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375 }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639 }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897 }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697 }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567 }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556 }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014 }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339 }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490 }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398 }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515 }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806 }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340 }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106 }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504 }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561 }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 }, +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] @@ -872,9 +1130,9 @@ dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/27/5247f2ccdf0f5764ad33fe695499e387e812c009501acbd0a0fa4871a042/types_factory_boy-0.4.1.tar.gz", hash = "sha256:275b9e17a6ad79992bec77b956bb707770f7b0256e4172265e2139e48faf6faf", size = 9552 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/27/5247f2ccdf0f5764ad33fe695499e387e812c009501acbd0a0fa4871a042/types_factory_boy-0.4.1.tar.gz", hash = "sha256:275b9e17a6ad79992bec77b956bb707770f7b0256e4172265e2139e48faf6faf", size = 9552, upload-time = "2023-03-12T19:25:37.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/b9/6ee0008ca399df8209e7c6eee242fb150dff831589ebd12a375d02898545/types_factory_boy-0.4.1-py3-none-any.whl", hash = "sha256:a1fa265fad8b24ddf2d4363227d675d0c4cd44951b71d68ea8fc58aa90b0f85d", size = 12974 }, + { url = "https://files.pythonhosted.org/packages/4a/b9/6ee0008ca399df8209e7c6eee242fb150dff831589ebd12a375d02898545/types_factory_boy-0.4.1-py3-none-any.whl", hash = "sha256:a1fa265fad8b24ddf2d4363227d675d0c4cd44951b71d68ea8fc58aa90b0f85d", size = 12974, upload-time = "2023-03-12T19:25:36.176Z" }, ] [[package]] @@ -884,9 +1142,9 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078, upload-time = "2024-12-30T02:44:38.168Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029 }, + { url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029, upload-time = "2024-12-30T02:44:36.162Z" }, ] [[package]] @@ -898,9 +1156,9 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", "python_full_version == '3.9.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522 } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338 }, + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, ] [[package]] @@ -910,9 +1168,9 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.9'", ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, ] [[package]] @@ -924,16 +1182,65 @@ resolution-markers = [ "python_full_version >= '3.10' and python_full_version < '3.12'", "python_full_version == '3.9.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "tzdata" version = "2025.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "tzdata", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version >= '3.10' and python_full_version < '3.12'", +] +dependencies = [ + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/52/ee2e6d7031687c5bad28363148cb72f2bbf38201d2e220671bd9fb830bc2/tzlocal-5.4.tar.gz", hash = "sha256:41e1293f80d4b5ff38dff222601a8fbd06b4fdcaf25e224704047ad26a39af54", size = 30922, upload-time = "2026-06-15T12:06:56.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/70/5771c9ecbdb7cc0c3f3bbded7e0fa7911ee8e872ce5b5dc48ce7dce21a11/tzlocal-5.4-py3-none-any.whl", hash = "sha256:024d11221ff83453eae1f608f09b145b9779e1345d08c15404ce8ff7917cf629", size = 28261, upload-time = "2026-06-15T12:06:54.914Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, ]