From d6bb754673ba76d0750d8206d19180feb1870a31 Mon Sep 17 00:00:00 2001 From: Ashwin Date: Sun, 2 Aug 2026 20:08:12 +0530 Subject: [PATCH] Add documentation for self-hosting, production deployment, requirements, security hardening, and troubleshooting - Created production-deploy.md to outline production deployment steps and configurations. - Added requirements.md detailing environment prerequisites for self-hosting. - Introduced security-hardening.md as a checklist for securing multi-tenant deployments. - Developed troubleshooting.md to address common issues in RLS-backed deployments. - Configured mkdocs.yml for documentation structure and navigation. --- .github/workflows/tests.yml | 40 + .gitignore | 2 + .readthedocs.yaml | 21 + CONTRIBUTING.md | 91 +- README.md | 387 +-- RLS_SETUP.md | 318 +-- .../common/tests/test_docs_api_endpoints.py | 169 ++ .../test_docs_build_excludes_internal.py | 62 + .../tests/test_docs_environment_variables.py | 94 + backend/pyproject.toml | 3 + backend/scripts/generate_endpoint_index.py | 59 + backend/uv.lock | 2413 +++++++++-------- docs/api/.endpoint-check-allowlist | 4 + docs/api/accounts.md | 160 ++ docs/api/authentication.md | 256 ++ docs/api/cases.md | 439 +++ docs/api/contacts.md | 207 ++ docs/api/conventions.md | 202 ++ docs/api/endpoint-index.md | 221 ++ docs/api/errors.md | 143 + docs/api/invoices.md | 315 +++ docs/api/leads.md | 246 ++ docs/api/opportunities.md | 186 ++ docs/api/tasks.md | 292 ++ docs/api/tokens-and-api-keys.md | 174 ++ docs/architecture/authentication.md | 206 ++ docs/architecture/background-jobs.md | 119 + docs/architecture/data-model.md | 129 + docs/architecture/multi-tenancy-and-rls.md | 211 ++ docs/architecture/overview.md | 120 + docs/architecture/permissions-and-roles.md | 188 ++ .../adding-an-org-scoped-model.md | 256 ++ docs/contributing/code-style.md | 91 + docs/contributing/development-setup.md | 123 + docs/contributing/pull-requests.md | 81 + docs/contributing/repo-layout.md | 105 + docs/contributing/security-rules.md | 182 ++ docs/contributing/testing.md | 113 + docs/getting-started/demo-data.md | 101 + docs/getting-started/docker-quick-start.md | 136 + docs/getting-started/first-sign-in.md | 141 + docs/getting-started/manual-setup.md | 171 ++ docs/index.md | 23 + docs/integrations/csv-import.md | 179 ++ docs/integrations/inbound-email.md | 157 ++ docs/integrations/javascript-sample.md | 172 ++ docs/integrations/mcp-server.md | 204 ++ docs/integrations/python-sample.md | 177 ++ docs/mobile/build-and-configure.md | 109 + docs/mobile/connect-to-self-hosted.md | 221 ++ docs/mobile/release-builds.md | 163 ++ docs/reference/environment-variables.md | 114 + docs/reference/glossary.md | 126 + docs/reference/management-commands.md | 152 ++ docs/self-hosting/backups-and-upgrades.md | 96 + docs/self-hosting/docker.md | 114 + docs/self-hosting/email-and-celery.md | 115 + docs/self-hosting/environment-variables.md | 80 + docs/self-hosting/google-oauth.md | 86 + docs/self-hosting/postgresql-and-rls.md | 231 ++ docs/self-hosting/production-deploy.md | 118 + docs/self-hosting/requirements.md | 65 + docs/self-hosting/security-hardening.md | 101 + docs/self-hosting/troubleshooting.md | 109 + mkdocs.yml | 112 + 65 files changed, 10173 insertions(+), 1828 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 backend/common/tests/test_docs_api_endpoints.py create mode 100644 backend/common/tests/test_docs_build_excludes_internal.py create mode 100644 backend/common/tests/test_docs_environment_variables.py create mode 100644 backend/scripts/generate_endpoint_index.py create mode 100644 docs/api/.endpoint-check-allowlist create mode 100644 docs/api/accounts.md create mode 100644 docs/api/authentication.md create mode 100644 docs/api/cases.md create mode 100644 docs/api/contacts.md create mode 100644 docs/api/conventions.md create mode 100644 docs/api/endpoint-index.md create mode 100644 docs/api/errors.md create mode 100644 docs/api/invoices.md create mode 100644 docs/api/leads.md create mode 100644 docs/api/opportunities.md create mode 100644 docs/api/tasks.md create mode 100644 docs/api/tokens-and-api-keys.md create mode 100644 docs/architecture/authentication.md create mode 100644 docs/architecture/background-jobs.md create mode 100644 docs/architecture/data-model.md create mode 100644 docs/architecture/multi-tenancy-and-rls.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/permissions-and-roles.md create mode 100644 docs/contributing/adding-an-org-scoped-model.md create mode 100644 docs/contributing/code-style.md create mode 100644 docs/contributing/development-setup.md create mode 100644 docs/contributing/pull-requests.md create mode 100644 docs/contributing/repo-layout.md create mode 100644 docs/contributing/security-rules.md create mode 100644 docs/contributing/testing.md create mode 100644 docs/getting-started/demo-data.md create mode 100644 docs/getting-started/docker-quick-start.md create mode 100644 docs/getting-started/first-sign-in.md create mode 100644 docs/getting-started/manual-setup.md create mode 100644 docs/index.md create mode 100644 docs/integrations/csv-import.md create mode 100644 docs/integrations/inbound-email.md create mode 100644 docs/integrations/javascript-sample.md create mode 100644 docs/integrations/mcp-server.md create mode 100644 docs/integrations/python-sample.md create mode 100644 docs/mobile/build-and-configure.md create mode 100644 docs/mobile/connect-to-self-hosted.md create mode 100644 docs/mobile/release-builds.md create mode 100644 docs/reference/environment-variables.md create mode 100644 docs/reference/glossary.md create mode 100644 docs/reference/management-commands.md create mode 100644 docs/self-hosting/backups-and-upgrades.md create mode 100644 docs/self-hosting/docker.md create mode 100644 docs/self-hosting/email-and-celery.md create mode 100644 docs/self-hosting/environment-variables.md create mode 100644 docs/self-hosting/google-oauth.md create mode 100644 docs/self-hosting/postgresql-and-rls.md create mode 100644 docs/self-hosting/production-deploy.md create mode 100644 docs/self-hosting/requirements.md create mode 100644 docs/self-hosting/security-hardening.md create mode 100644 docs/self-hosting/troubleshooting.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b54a240..778face3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,9 @@ on: paths: - 'backend/**' - 'frontend/**' + - 'docs/**' + - 'mkdocs.yml' + - '.readthedocs.yaml' - '.coveragerc' - '.github/workflows/tests.yml' pull_request: @@ -13,6 +16,9 @@ on: paths: - 'backend/**' - 'frontend/**' + - 'docs/**' + - 'mkdocs.yml' + - '.readthedocs.yaml' - '.coveragerc' - '.github/workflows/tests.yml' @@ -120,3 +126,37 @@ jobs: - name: Build working-directory: frontend run: pnpm build + + docs-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: 'backend/uv.lock' + + - name: Install docs dependencies + working-directory: backend + run: uv sync --frozen --group docs + + # --strict fails on broken internal links and nav entries pointing at + # files that do not exist. + - name: Build docs + working-directory: backend + run: uv run mkdocs build --strict -f ../mkdocs.yml -d site + + # These two tests skip when mkdocs is absent, so the backend-tests job + # (which installs no docs group) cannot run them. Without this step the + # "no internal artifacts published" guard would silently never execute + # in CI — a skip reads as a pass. + - name: Check docs build guards + working-directory: backend + env: + DJANGO_SETTINGS_MODULE: crm.test_settings + SECRET_KEY: test-secret-key-for-ci + ADMIN_EMAIL: admin@test.com + run: uv run pytest common/tests/test_docs_build_excludes_internal.py --no-cov -v diff --git a/.gitignore b/.gitignore index 0ce87cf5..4e4205f4 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,8 @@ coverage-badge.svg # Documentation docs/build/ +# MkDocs build output +site/ # Node.js / Frontend frontend/node_modules/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..9e05b615 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,21 @@ +# Read the Docs build configuration. +# The docs dependencies live in backend/pyproject.toml's `docs` group so the +# site builds with uv, exactly as CI does — one dependency source, one lockfile. +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + jobs: + install: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + - cd backend && uv sync --frozen --group docs + build: + html: + - cd backend && uv run mkdocs build --strict -f ../mkdocs.yml -d $READTHEDOCS_OUTPUT/html + +mkdocs: + configuration: mkdocs.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 395ab98d..13cad521 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,97 +8,26 @@ Thank you for helping improve BottleCRM. Contributions of all sizes are welcome, - For a substantial feature or architectural change, open an issue first so the approach can be discussed before implementation begins. - Keep pull requests focused. Unrelated changes are easier to review when submitted separately. -## Prerequisites +## Development Setup -- Python 3.12+ -- [`uv`](https://docs.astral.sh/uv/) -- Node.js 24 -- pnpm 10 -- PostgreSQL 14+ -- Redis 7+ +Full instructions — cloning, Docker, the backend (`uv`), the frontend (`pnpm`), running the mobile app, adding dependencies, and running tests — live in the documentation site under Contributing: -You can also use Docker Compose, which provides the application services and development dependencies. - -## Set Up the Project - -Clone your fork and enter the repository: - -```bash -git clone https://github.com//Django-CRM.git -cd Django-CRM -``` - -### Docker Setup - -To start the full development stack: - -```bash -docker compose up --build -``` - -The frontend is available at `http://localhost:5173`, and the backend API is available at `http://localhost:8000`. - -### Backend Setup - -Start PostgreSQL and Redis, create the database named in `backend/.env.example`, and ensure its credentials match your local PostgreSQL configuration. Then run: - -```bash -cd backend -uv sync -cp .env.example .env -uv run python manage.py migrate -uv run python manage.py runserver -``` - -To process background jobs, run the Celery worker in a separate terminal: - -```bash -cd backend -uv run celery -A crm worker --loglevel=INFO -``` - -### Frontend Setup - -```bash -cd frontend -pnpm install -pnpm run dev -``` - -## Development Checks - -Run the backend test suite from `backend/`: - -```bash -uv run pytest -``` - -When changing Django models, generate and commit the migration, then verify that no model changes are missing migrations: - -```bash -uv run python manage.py makemigrations -uv run python manage.py makemigrations --check --dry-run -``` - -Run the frontend checks from `frontend/`: - -```bash -pnpm run lint -pnpm run check -pnpm run build -``` - -Use `pnpm run format` to apply the configured frontend formatting rules. +- [Development setup](docs/contributing/development-setup.md) — get all three projects running locally. +- [Testing](docs/contributing/testing.md) — running the suite, markers, and what CI runs. +- [Code style](docs/contributing/code-style.md) — Python, frontend, and Flutter conventions, and what CI does and doesn't enforce. +- [Repository layout](docs/contributing/repo-layout.md) — where each part of the codebase lives. +- [Security rules](docs/contributing/security-rules.md) — the rules this codebase treats as non-negotiable. +- [Adding an org-scoped model](docs/contributing/adding-an-org-scoped-model.md) — what a new multi-tenant model needs, including its RLS policy. ## Submitting a Pull Request 1. Create a branch from `master` with a descriptive name. 2. Make focused changes and include tests where appropriate. -3. Run the relevant development checks. +3. Run the relevant development checks — see [Testing](docs/contributing/testing.md) and [Code style](docs/contributing/code-style.md). 4. Update documentation when behavior, setup, or public APIs change. 5. Push your branch and open a pull request against `master`. -In the pull request description, explain what changed, why it changed, how it was tested, and include screenshots for visible UI changes. +In the pull request description, explain what changed, why it changed, how it was tested, and include screenshots for visible UI changes. See [Pull requests](docs/contributing/pull-requests.md) for what CI runs and what review focuses on. ## Pull Request Checklist diff --git a/README.md b/README.md index d8fcd745..68fd1fd8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A free, self-hosted, multi-tenant CRM built with Django REST Framework, SvelteKi ![Django](https://img.shields.io/badge/django-6.x-green.svg) ![SvelteKit](https://img.shields.io/badge/sveltekit-2.x-orange.svg) ![Svelte](https://img.shields.io/badge/svelte-5-orange.svg) -![Flutter](https://img.shields.io/badge/flutter-3.8+-blue.svg) +![Flutter](https://img.shields.io/badge/flutter-3.44+-blue.svg) ![Coverage](./coverage-badge.svg) **BottleCRM is an open source CRM you run on your own infrastructure.** It covers the full @@ -19,7 +19,9 @@ the database layer, so a single deployment serves one startup or hundreds of ten No per-seat pricing, no user caps, no feature paywall. MIT licensed — fork it, self-host it, own your data. -**[Try it free →](https://bottlecrm.io/)** · [Features](https://bottlecrm.io/features) · [Docs](https://bottlecrm.io/docs) · [Pricing](https://bottlecrm.io/pricing) · [Migrate from Salesforce or HubSpot](https://bottlecrm.io/migration) +**[Try it free →](https://bottlecrm.io/)** · [Features](https://bottlecrm.io/features) · [Product docs](https://bottlecrm.io/docs) · [Pricing](https://bottlecrm.io/pricing) · [Migrate from Salesforce or HubSpot](https://bottlecrm.io/migration) + +(That's the marketing site's product documentation. For the technical documentation — self-hosting, architecture, the API, contributing — see [Documentation](#documentation) below.) ## Why BottleCRM @@ -49,383 +51,28 @@ your data. | **Stack** | Django REST + SvelteKit | Closed source | Varies | | **License** | MIT | Proprietary | Varies, often GPL/AGPL | -## Features - -### Core CRM Modules -- **[Leads](https://bottlecrm.io/features/lead-management)** - Capture, score and convert sales leads through your pipeline -- **[Accounts](https://bottlecrm.io/features/account-management)** - Manage company/organization records -- **[Contacts](https://bottlecrm.io/features/contact-management)** - Store and organize contact information -- **[Opportunities](https://bottlecrm.io/features/sales-pipeline)** - Visual sales pipeline for deals and forecasting -- **[Tickets & Cases](https://bottlecrm.io/features/ticket-management)** - Helpdesk with SLA tracking, approvals, escalations, macros and a knowledge base -- **[Tasks](https://bottlecrm.io/features/tasks)** - Task management with calendar and Kanban board views -- **[Invoices](https://bottlecrm.io/features/invoices)** - Estimates, recurring invoices and online payments - -### AI & Integrations -- **AI Agents (MCP)** - Built-in [Model Context Protocol](https://modelcontextprotocol.io) server (`mcp_server/`) lets Claude, Cursor, Codex, Gemini and any MCP client search, create and update records via a personal access token — acting as you, with your role and permissions. See [`mcp_server/README.md`](mcp_server/README.md). -- **REST API** - Every feature is API-first and documented via OpenAPI/Swagger -- **Email Integration** - AWS SES integration for transactional emails - -### Platform Features -- **[Native Mobile App](https://bottlecrm.io/features/mobile-app)** - Flutter app for iOS and Android covering leads, deals, tickets, tasks, goals and timesheets -- **Multi-Tenant Architecture** - PostgreSQL Row-Level Security keeps each organization's CRM data isolated -- **JWT Authentication** - Secure token-based authentication -- **Team Management** - Organize users into teams with role-based access -- **Activity Tracking** - Comprehensive audit logs and activity history -- **Comments & Attachments** - Collaborate with comments and file attachments on any record -- **Tags** - Flexible tagging system for organizing records -- **Background Tasks** - Celery + Redis for async task processing - -### Built for your industry -Pre-configured setups for [professional services and agencies](https://bottlecrm.io/industries/professional-services-crm), [education and admissions](https://bottlecrm.io/industries/education-admissions-crm), and [real estate](https://bottlecrm.io/industries/real-estate-crm). - -## Tech Stack - -### Backend -- **Django 6.x** with Django REST Framework -- **PostgreSQL** for relational data storage -- **Redis** for caching and Celery broker -- **Celery** for background task processing -- **JWT** for authentication -- **AWS S3** for file storage -- **AWS SES** for email delivery - -### Frontend -- **SvelteKit 2.x** with Svelte 5 (runes) -- **TailwindCSS 4** for styling -- **shadcn-svelte** UI components -- **Axios** for API communication -- **Lucide** icons - -### Mobile -- **Flutter 3.8+** targeting iOS and Android -- **Material Design 3** with a flat, custom design system -- **Google Sign-In** + JWT against the same backend API - -### AI -- **MCP server** (`bcrm-mcp`) built on FastMCP, stdio and HTTP transports - -## Quick Start - -### Prerequisites -- Python 3.12+ -- Node.js 24 with pnpm 10 -- PostgreSQL 14+ -- Redis -- Flutter 3.8+ (only if you're building the mobile app) - -### Backend Setup - -The backend uses [`uv`](https://docs.astral.sh/uv/) for Python dependency management — it reads `pyproject.toml`, installs from `uv.lock`, and creates the virtual environment for you. Backend configuration starts from [`backend/.env.example`](backend/.env.example). +## Quick start ```bash -# Clone the repository git clone https://github.com/django-crm/Django-CRM.git -cd Django-CRM/backend - -# Install uv (one time, system-wide) -curl -LsSf https://astral.sh/uv/install.sh | sh -# Or on macOS via Homebrew: brew install uv - -# Install Python (matches the version in .python-version) and all deps into .venv/ -uv sync - -# Set up environment variables -cp .env.example .env -# Edit .env with your database and other settings - -# Run migrations -uv run python manage.py migrate - -# Create a superuser -uv run python manage.py createsuperuser - -# Start the development server -uv run python manage.py runserver -``` - -`uv run ` resolves binaries from `.venv/bin/` automatically — no need to `source .venv/bin/activate`. If you prefer the activate-then-run flow, that still works: - -```bash -source .venv/bin/activate -python manage.py runserver # equivalent to `uv run python manage.py runserver` -``` - -Common dev commands (from `backend/`): - -```bash -uv run pytest # run tests -uv run python manage.py makemigrations # create migrations -uv run celery -A crm worker --loglevel=INFO # background worker -uv add # add a dependency (updates pyproject.toml + uv.lock) -uv add --group dev # add a dev-only dependency -uv lock --upgrade # refresh the lockfile -``` - -### Frontend Setup - -```bash -# In a new terminal, from the project root -cd frontend - -# Install dependencies -pnpm install - -# Start the development server -pnpm run dev -``` - -### Mobile Setup (optional) - -```bash -# In a new terminal, from the project root -cd mobile - -flutter pub get -flutter run # against a running backend on :8000 -``` - -See [`mobile/README.md`](mobile/README.md) for emulator setup and release builds. - -### Start Celery Worker - -```bash -# In a new terminal -cd backend -uv run celery -A crm worker --loglevel=INFO -``` - -### Access the Application -- **Frontend**: http://localhost:5173 -- **API Documentation**: http://localhost:8000/swagger-ui/ -- **Admin Panel**: http://localhost:8000/admin/ - -### Connect your AI agent (MCP) - -Let Claude, Cursor, Codex, Gemini, or any MCP client work in your CRM: - -1. In the app, go to **Settings → API Tokens** and create a personal access token (shown once). -2. Register the `bcrm-mcp` server in your AI client, passing `BCRM_BASE_URL` (your API host, e.g. `http://localhost:8000`) and `BCRM_TOKEN` (the token). The token page shows ready-to-paste config for each client. -3. Restart the client and start asking. - -The agent authenticates **as you** and inherits your role, org and RLS scope — it can't see or do anything you can't. Full setup, the tool list, and the security model are in [`mcp_server/README.md`](mcp_server/README.md). - -## Docker Setup - -Run the full stack (backend, frontend, PostgreSQL, Redis, Celery) with a single command: - -```bash -# Start all services (first run will build images) -# An admin user (admin@localhost / admin) is created automatically +cd Django-CRM docker compose up --build - -# (Optional) Load sample data -docker compose exec backend python manage.py seed_data --email admin@example.com ``` -Once running: -- **Frontend**: http://localhost:5173 -- **API / Swagger**: http://localhost:8000/swagger-ui/ -- **PostgreSQL**: localhost:5432 -- **Redis**: localhost:6379 +Frontend at http://localhost:5173, API and Swagger UI at http://localhost:8000/swagger-ui/. See +the [Docker quick start guide](docs/getting-started/docker-quick-start.md) for the default admin +login, loading demo data, and what each service does. -### Daily workflow +## Documentation -```bash -docker compose up # start all services (code changes auto-reload) -docker compose down # stop all services -docker compose down -v # stop and delete all data (full reset) -``` +The full documentation — setup, self-hosting, architecture, the REST API, and contributing — is +built from this repository's [`docs/`](docs/) with MkDocs Material and publishes to Read the Docs +at . Four starting points: -### Running commands inside containers - -```bash -docker compose exec backend python manage.py migrate -docker compose exec backend python -m pytest -docker compose exec backend python manage.py manage_rls --status -``` - -### Custom environment overrides - -The default env vars live in `.env.docker` (committed). To override locally without touching git: - -1. Copy `.env.docker` to `.env.docker.local` (gitignored) -2. Edit values as needed — set your own `SECRET_KEY` here, not in `.env.docker` -3. Rebuild/restart: `docker compose up --build` - -Every service loads `.env.docker` first and then `.env.docker.local` if it exists, so -your overrides win. No edits to `docker-compose.yml` are needed. - -## Project Structure - -``` -Django-CRM/ -├── backend/ # Django REST API -│ ├── accounts/ # Accounts module -│ ├── cases/ # Cases module -│ ├── common/ # Shared models, utilities, RLS -│ ├── contacts/ # Contacts module -│ ├── invoices/ # Invoices module -│ ├── leads/ # Leads module -│ ├── opportunity/ # Opportunities module -│ ├── tasks/ # Tasks module -│ └── crm/ # Django project settings -├── frontend/ # SvelteKit frontend -│ ├── src/ -│ │ ├── lib/ # Components, stores, utilities -│ │ └── routes/ # SvelteKit routes -│ │ ├── (app)/ # Authenticated app routes -│ │ └── (no-layout)/ # Auth pages (login, etc.) -│ ├── static/ # Static assets -│ └── Dockerfile # Frontend dev container -├── mobile/ # Flutter app (iOS + Android) -│ ├── lib/ -│ │ ├── config/ # API configuration -│ │ ├── services/ # API + auth services -│ │ ├── models/ # Typed API response models -│ │ └── screens/ # Leads, deals, tickets, tasks, settings -│ ├── android/ # Android build config -│ └── ios/ # iOS build config -├── mcp_server/ # MCP server (bcrm-mcp) for AI agents -│ └── src/bcrm_mcp/ # FastMCP tools over the REST API (stdio transport) -├── docker/ # Docker support files -│ ├── backend/ -│ │ └── entrypoint.sh # DB wait + migrate + runserver -│ └── postgres/ -│ └── init-rls-user.sql # Creates non-superuser for RLS -├── Dockerfile # Backend / Celery image -├── docker-compose.yml # Full-stack dev environment -└── .env.docker # Docker env vars (dev defaults) -``` - -## Multi-Tenancy & Security - -BottleCRM isolates tenant-scoped data at both the application and the database layer. Every -org-scoped table is protected by a PostgreSQL Row-Level Security policy keyed on the -`app.current_org` session variable, which middleware sets from the authenticated user's JWT — so -a query that escapes the ORM's org filter still returns zero rows rather than another tenant's -data. - -Self-hosters should follow the [Row-Level Security setup guide](RLS_SETUP.md) to configure and -verify the policies. The most important rule: **the application's database user must not be a -PostgreSQL superuser**, because superusers bypass RLS entirely. Verify with -`python manage.py manage_rls --status`. - -## Development - -### Testing - -```bash -cd backend - -# Run all tests with coverage -pytest - -# Run tests without coverage (faster) -pytest --no-cov -x - -# Run a specific module's tests -pytest accounts/tests/ -pytest leads/tests/test_leads_kanban.py - -# Run tests matching a keyword -pytest -k "test_login" - -# View coverage report in browser -open htmlcov/index.html -``` - -### Dev login (skip the Google OAuth flow) - -For local development you can mint a JWT for any user without going through Google sign-in. The command refuses to run unless `DEBUG=True`, and there's no web endpoint — it's only reachable through `manage.py`: - -```bash -cd backend - -# Mint tokens for an existing user (no org bound — same as the OAuth flow) -uv run python manage.py devlogin aswin.1231@gmail.com - -# Bind to a specific org so you skip the orgswitch step on first load -uv run python manage.py devlogin aswin.1231@gmail.com --org "MicroPyramid" - -# Create the user on the fly (random password) if they don't exist yet -uv run python manage.py devlogin newdev@example.com --create -``` - -The command prints the access token, the refresh token, and (with `--org`) the org's UUID, plus a -ready-to-paste `localStorage.setItem(...)` snippet. - -**Note:** `localStorage` alone is not enough to sign you in. The API client reads it for direct -fetches, but SvelteKit's server-side guard (`frontend/src/hooks.server.js`) reads the -`jwt_access`, `jwt_refresh` and `org` **cookies** — without them you'll still be redirected to -`/login`. In the devtools console on `http://localhost:5173`, run the printed snippet *and*: - -```js -const oneYear = 60 * 60 * 24 * 365; -document.cookie = `jwt_access=${ACCESS_TOKEN}; path=/; max-age=${oneYear}; samesite=lax`; -document.cookie = `jwt_refresh=${REFRESH_TOKEN}; path=/; max-age=${oneYear}; samesite=lax`; -document.cookie = `org=${ORG_UUID}; path=/; max-age=${oneYear}; samesite=lax`; -``` - -Then reload. The root route `/` is the dashboard — there is no `/dashboard` path. - -### Frontend Commands - -```bash -cd frontend - -# Type checking -pnpm run check - -# Linting -pnpm run lint - -# Formatting -pnpm run format -``` - -### Mobile Commands - -```bash -cd mobile - -flutter test # run tests -flutter analyze --no-fatal-infos # static analysis -dart format . # formatting -flutter build apk --release # Android release build -``` - -## API Documentation - -API routes vary by module and are documented from the generated OpenAPI schema. After starting the backend, open the [interactive Swagger UI](http://localhost:8000/swagger-ui/) to explore the available endpoints and request formats. - -## FAQ - -**Is BottleCRM really free?** -Yes. It's MIT licensed with unlimited users, unlimited records and no feature paywall. You only -pay if you want [managed hosting, setup or custom development](https://bottlecrm.io/pricing). - -**Can I self-host it?** -That's the primary way to run it. `docker compose up --build` gives you the full stack; see -[Docker Setup](#docker-setup). Your database, your server, your data. - -**Is it a good Salesforce or HubSpot alternative?** -For teams that want to own their data and avoid per-seat pricing, yes. It covers leads, pipeline, -contacts, accounts, tickets, tasks and invoicing. See the [migration guide](https://bottlecrm.io/migration) -for moving existing data across. - -**Can I run it as a multi-tenant SaaS?** -Yes — multi-tenancy is built in at the database layer via PostgreSQL Row-Level Security, not -bolted on. See [Multi-Tenancy & Security](#multi-tenancy--security). - -**Is there a mobile app?** -Yes, a native [Flutter app](https://bottlecrm.io/features/mobile-app) for iOS and Android sharing -the same backend API. - -**Can AI agents use it?** -Yes. The bundled [MCP server](mcp_server/README.md) lets Claude, Cursor, Codex, Gemini or any MCP -client work in your CRM under your own role and permissions. - -More at the [full FAQ](https://bottlecrm.io/faq). +- **[Getting started](docs/getting-started/docker-quick-start.md)** — Docker and manual setup, first sign-in, demo data. +- **[Self-hosting](docs/self-hosting/requirements.md)** — requirements, production deployment, PostgreSQL and Row-Level Security, backups, security hardening. +- **[API reference](docs/api/conventions.md)** — authentication, conventions, and every documented endpoint. +- **[Contributing](docs/contributing/development-setup.md)** — local development setup, testing, code style, and how to open a pull request. ## Contributing diff --git a/RLS_SETUP.md b/RLS_SETUP.md index 55060889..d73f27cf 100644 --- a/RLS_SETUP.md +++ b/RLS_SETUP.md @@ -1,305 +1,17 @@ # PostgreSQL Row-Level Security (RLS) Setup Guide -This guide explains how to set up PostgreSQL with Row-Level Security for BottleCRM multi-tenancy. - -## Overview - -RLS provides database-level tenant isolation. Every query automatically filters by organization, ensuring data protection even if application code misses a filter. - -**Key Points:** -- RLS uses PostgreSQL session variable `app.current_org` to identify the current tenant -- **Superusers bypass RLS** - the application must use a non-superuser database account -- 24 tables are RLS-protected (see list below) - ---- - -## 1. Create PostgreSQL Database - -```sql --- Connect as postgres superuser -sudo -u postgres psql - --- Create the database -CREATE DATABASE bottlecrm; -``` - ---- - -## 2. Create Application User (Non-Superuser) - -**CRITICAL:** The application user must NOT be a superuser, otherwise RLS is completely bypassed. - -```sql --- Connect as postgres superuser -sudo -u postgres psql - --- Create non-superuser for the application -CREATE USER crm_user WITH PASSWORD 'crm_password'; - --- Verify user is NOT a superuser -SELECT usename, usesuper FROM pg_user WHERE usename = 'crm_user'; --- Should show: crm_user | f -``` - ---- - -## 3. Grant Database Permissions - -```sql --- Connect to bottlecrm database -\c bottlecrm - --- Grant connection rights -GRANT CONNECT ON DATABASE bottlecrm TO crm_user; - --- Grant schema privileges (required for migrations to create/alter tables) -GRANT ALL ON SCHEMA public TO crm_user; - --- Grant table permissions (SELECT, INSERT, UPDATE, DELETE) -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO crm_user; - --- Grant sequence permissions (for auto-increment fields) -GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO crm_user; - --- Grant default privileges for future tables -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO crm_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO crm_user; -``` - -> **Note:** Granting schema/table privileges does NOT weaken RLS. These control DDL operations (CREATE/ALTER/DROP), while RLS controls row-level access. Only `BYPASSRLS` attribute or superuser status bypasses RLS. - ---- - -## 4. Configure Django Environment - -Set these environment variables for Django: - -```bash -export DBNAME=bottlecrm -export DBUSER=crm_user -export DBPASSWORD=crm_password -export DBHOST=localhost -export DBPORT=5432 -``` - -Or in your `.env` file: - -```env -DBNAME=bottlecrm -DBUSER=crm_user -DBPASSWORD=crm_password -DBHOST=localhost -DBPORT=5432 -``` - ---- - -## 5. Run Migrations - -Migrations automatically enable RLS on all protected tables: - -```bash -cd backend -uv run python manage.py migrate -``` - ---- - -## 6. Verify RLS Setup - -### Check RLS Status - -```bash -python manage.py manage_rls --status -``` - -Expected output: -``` -RLS Status: - - Database user "crm_user" is not a superuser - RLS will be enforced - - lead: ENABLED (forced) - accounts: ENABLED (forced) - contacts: ENABLED (forced) - ... - - Enabled: 24, Disabled: 0 -``` - -### Verify User is Not Superuser - -```bash -python manage.py manage_rls --verify-user -``` - -Expected output: -``` -Verifying database user... - Current user: crm_user - Is superuser: False - Can create DB: False -Database user is properly configured for RLS -``` - -### Test RLS Isolation - -```bash -python manage.py manage_rls --test -``` - -This tests that: -- Data is visible with correct org context -- Data is isolated between organizations -- No data is returned without context (fail-safe) - ---- - -## 7. RLS-Protected Tables (24) - -| Category | Tables | -|----------|--------| -| Core Business | `lead`, `accounts`, `contacts`, `opportunity`, `case`, `task`, `invoice` | -| Supporting | `comment`, `commentFiles`, `attachments`, `document`, `teams`, `activity`, `tags`, `address`, `solution` | -| Boards (Kanban) | `board`, `board_column`, `board_task`, `board_member` | -| Settings/Email | `apiSettings`, `account_email`, `emailLogs`, `invoice_history` | -| Security | `security_audit_log` | - ---- - -## 8. Production Checklist - -- [ ] Application database user is NOT a superuser -- [ ] All 24 tables show RLS ENABLED (forced) -- [ ] `manage_rls --test` passes -- [ ] `manage_rls --verify-user` confirms non-superuser -- [ ] Environment variables are securely stored (not in version control) - ---- - -## Troubleshooting - -### "RLS will be bypassed" Warning - -The database user is a superuser. Create a non-superuser as shown in Step 2. - -### Tables Show "disabled" - -Run migrations again or check if the table has an `org_id` column: - -```sql -SELECT column_name FROM information_schema.columns -WHERE table_name = 'your_table' AND column_name = 'org_id'; -``` - -### Data Visible Without Context - -If `manage_rls --test` shows data without context, the RLS policies may need updating: - -```bash -python manage.py migrate common -``` - -### Permission Denied Errors - -Grant permissions again (Step 3), especially after creating new tables: - -```sql -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_user; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_user; -``` - ---- - -## How RLS Works - -### Session Context - -Each request sets the org context via middleware: - -```sql -SELECT set_config('app.current_org', '', true); -``` - -### Policy Definition - -Each table has two policies: - -1. **Isolation Policy** (SELECT/UPDATE/DELETE): - ```sql - CREATE POLICY org_isolation ON "lead" - FOR ALL - USING (org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))); - ``` - -2. **Insert Check Policy**: - ```sql - CREATE POLICY org_insert_check ON "lead" - FOR INSERT - WITH CHECK (org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))); - ``` - -The `NULLIF(..., '')` ensures **no rows are returned** when context is not set (fail-safe). - -### Force RLS - -```sql -ALTER TABLE "lead" FORCE ROW LEVEL SECURITY; -``` - -This ensures RLS applies even to the table owner. - ---- - -## Adding RLS to New Tables - -1. Inherit from `BaseOrgModel` in your model -2. Add table name to `ORG_SCOPED_TABLES` in `common/rls/__init__.py` -3. Create migration: - -```python -from django.db import migrations -from common.rls import get_enable_policy_sql - -def enable_rls(apps, schema_editor): - if schema_editor.connection.vendor != 'postgresql': - return - with schema_editor.connection.cursor() as cursor: - cursor.execute(get_enable_policy_sql('your_table_name')) - -class Migration(migrations.Migration): - dependencies = [...] - operations = [ - migrations.RunPython(enable_rls, migrations.RunPython.noop), - ] -``` - ---- - -## Celery Tasks - -Background tasks don't go through middleware, so you must set context manually: - -```python -from common.tasks import set_rls_context - -@app.task -def my_task(data_id, org_id): - set_rls_context(org_id) # REQUIRED before any query - obj = MyModel.objects.get(id=data_id) -``` - ---- - -## Quick Reference - -```bash -# Check RLS status -python manage.py manage_rls --status - -# Verify non-superuser -python manage.py manage_rls --verify-user - -# Test isolation -python manage.py manage_rls --test -``` +This file has moved into the documentation site. See +[PostgreSQL and RLS](docs/self-hosting/postgresql-and-rls.md) for the full guide: creating the +non-superuser application role, how RLS policies are enabled by migrations, verifying isolation +with `manage_rls`, Celery's `set_rls_context`, and the mistakes that most often leave a deployment +unprotected. + +Two related pages worth knowing about too: [Security hardening](docs/self-hosting/security-hardening.md#database-role) +covers the database role as part of a production checklist, and +[Troubleshooting](docs/self-hosting/troubleshooting.md) covers the specific symptoms of a +misconfigured role (empty admin lists, data visible across organizations, and more). + +This file is kept only so existing links to `RLS_SETUP.md` keep resolving; it is not maintained +as a setup guide in its own right. Notably, the number of RLS-protected tables cited in earlier +versions of this file (24) was out of date — the current, verified count is documented on the +linked page rather than repeated here, precisely so this file can't drift out of sync again. diff --git a/backend/common/tests/test_docs_api_endpoints.py b/backend/common/tests/test_docs_api_endpoints.py new file mode 100644 index 00000000..b4811568 --- /dev/null +++ b/backend/common/tests/test_docs_api_endpoints.py @@ -0,0 +1,169 @@ +"""Every endpoint named in docs/api/ must exist in the generated OpenAPI schema. + +The API reference is hand-written by choice, so nothing else stops it drifting +from the code. The current bottlecrm.io docs document `POST /api/auth/refresh/`; +the real route is `POST /api/auth/refresh-token/`. This test is what catches +that class of error. + +The check runs one way only: documented endpoints must exist. It does not +require every schema path to be documented, because the reference is curated. +""" + +import re +from pathlib import Path + +from django.conf import settings +from drf_spectacular.generators import SchemaGenerator + +DOCS_API_DIR = Path(settings.BASE_DIR).parent / "docs" / "api" +ALLOWLIST_PATH = DOCS_API_DIR / ".endpoint-check-allowlist" + +HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") + +# Matches "GET /api/leads/{id}/" in prose, tables, and fenced code blocks. +# Stops at whitespace and at the characters that delimit markdown table cells, +# inline code, and quoted strings. +ENDPOINT_RE = re.compile( + r"\b(" + "|".join(HTTP_METHODS) + r")\s+(/api/[^\s`'\"|)\]<>]*(?:<[^>]*>[^\s`'\"|)\]<>]*)*)" +) + +# Path parameters appear as {id} in the schema and as {id} or in docs. +PARAM_RE = re.compile(r"\{[^}]*\}|<[^>]*>") + + +def _normalise_path(path: str) -> str: + """Collapse parameter names and trailing punctuation so docs and schema compare.""" + path = path.split("?", 1)[0].split("#", 1)[0] + path = path.rstrip(".,;:") + if not path.endswith("/"): + path += "/" + return PARAM_RE.sub("{}", path) + + +def _extract_endpoints(text: str) -> list[tuple[str, str]]: + return [ + (method, _normalise_path(raw)) for method, raw in ENDPOINT_RE.findall(text) + ] + + +def _schema_operations() -> dict[str, set[str]]: + schema = SchemaGenerator().get_schema(request=None, public=True) + operations: dict[str, set[str]] = {} + for path, methods in schema["paths"].items(): + key = _normalise_path(path) + operations.setdefault(key, set()).update( + method.upper() for method in methods if method.upper() in HTTP_METHODS + ) + return operations + + +def _load_allowlist() -> set[str]: + if not ALLOWLIST_PATH.exists(): + return set() + entries = set() + for line in ALLOWLIST_PATH.read_text().splitlines(): + entry = line.split("#", 1)[0].strip() + if entry: + entries.add(entry) + return entries + + +def _find_missing( + documented: list[tuple[str, str, str]], + operations: dict[str, set[str]], + allowlist: set[str], +) -> list[str]: + """documented is a list of (source_filename, method, normalised_path).""" + missing = [] + for filename, method, path in documented: + if f"{method} {path}" in allowlist: + continue + if path not in operations: + missing.append(f"{filename}: {method} {path} — no such path in the API") + elif method not in operations[path]: + missing.append( + f"{filename}: {method} {path} — path exists but does not accept " + f"{method} (accepts {', '.join(sorted(operations[path]))})" + ) + return missing + + +def test_docs_api_directory_exists(): + # Without this, every check below passes vacuously if the directory is + # renamed or moved: Path.glob() on a missing directory yields nothing + # rather than raising, so the whole guard would silently stop guarding. + assert DOCS_API_DIR.is_dir(), f"{DOCS_API_DIR} is missing" + + +def test_normalise_path_collapses_parameters_and_trailing_slash(): + assert _normalise_path("/api/leads/{id}/") == "/api/leads/{}/" + assert _normalise_path("/api/leads//") == "/api/leads/{}/" + assert _normalise_path("/api/leads") == "/api/leads/" + + +def test_normalise_path_strips_query_strings(): + # Documented filter examples carry a query string; the schema path does not. + # Without stripping, a correct page fails the check. + assert _normalise_path("/api/leads/?status=open") == "/api/leads/" + assert _normalise_path("/api/leads/?status=open&page=2") == "/api/leads/" + assert _normalise_path("/api/leads/{id}/?expand=account") == "/api/leads/{}/" + + +def test_extract_endpoints_reads_prose_tables_and_code_blocks(): + text = ( + "Call `GET /api/leads/` to list them.\n" + "| POST /api/leads/ | create |\n" + "```http\nDELETE /api/leads/{id}/\n```\n" + ) + assert _extract_endpoints(text) == [ + ("GET", "/api/leads/"), + ("POST", "/api/leads/"), + ("DELETE", "/api/leads/{}/"), + ] + + +def test_find_missing_reports_a_path_that_does_not_exist(): + # The real-world case: /api/auth/refresh/ does not exist; the route is + # /api/auth/refresh-token/. + operations = {"/api/auth/refresh-token/": {"POST"}} + missing = _find_missing( + [("authentication.md", "POST", "/api/auth/refresh/")], operations, set() + ) + assert len(missing) == 1 + assert "no such path" in missing[0] + + +def test_find_missing_reports_a_method_the_path_does_not_accept(): + operations = {"/api/leads/": {"GET", "POST"}} + missing = _find_missing([("leads.md", "DELETE", "/api/leads/")], operations, set()) + assert len(missing) == 1 + assert "does not accept DELETE" in missing[0] + + +def test_find_missing_is_empty_when_everything_resolves(): + operations = {"/api/leads/": {"GET", "POST"}} + documented = [("leads.md", "GET", "/api/leads/"), ("leads.md", "POST", "/api/leads/")] + assert _find_missing(documented, operations, set()) == [] + + +def test_find_missing_honours_the_allowlist(): + operations = {"/api/leads/": {"GET"}} + documented = [("leads.md", "POST", "/api/planned/")] + assert _find_missing(documented, operations, {"POST /api/planned/"}) == [] + + +def test_every_documented_api_endpoint_exists(): + operations = _schema_operations() + allowlist = _load_allowlist() + documented = [ + (path.name, method, endpoint) + for path in sorted(DOCS_API_DIR.glob("*.md")) + for method, endpoint in _extract_endpoints(path.read_text()) + ] + missing = _find_missing(documented, operations, allowlist) + assert not missing, ( + "docs/api/ documents endpoints that do not exist:\n " + + "\n ".join(missing) + + "\n\nFix the docs, or add an entry to docs/api/.endpoint-check-allowlist " + "with a comment explaining why." + ) diff --git a/backend/common/tests/test_docs_build_excludes_internal.py b/backend/common/tests/test_docs_build_excludes_internal.py new file mode 100644 index 00000000..8a71f8ca --- /dev/null +++ b/backend/common/tests/test_docs_build_excludes_internal.py @@ -0,0 +1,62 @@ +"""The docs/ directory holds internal specs and plans alongside published pages. + +MkDocs copies every file under docs_dir into the built site, including files no +nav entry references. Without an explicit exclusion, implementation plans would +be published to a public documentation site. This test is the guard. +""" + +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest +from django.conf import settings + +REPO_ROOT = Path(settings.BASE_DIR).parent +MKDOCS_CONFIG = REPO_ROOT / "mkdocs.yml" +INTERNAL_PREFIXES = ("superpowers", "design") + + +def _build_site(destination: Path) -> None: + subprocess.run( + [ + "mkdocs", + "build", + "--strict", + "-f", + str(MKDOCS_CONFIG), + "-d", + str(destination), + ], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + + +@pytest.mark.skipif(shutil.which("mkdocs") is None, reason="mkdocs not installed") +def test_built_site_contains_no_internal_artifacts(): + with tempfile.TemporaryDirectory() as tmp: + destination = Path(tmp) / "site" + _build_site(destination) + leaked = sorted( + str(p.relative_to(destination)) + for p in destination.rglob("*") + if p.is_file() + and p.relative_to(destination).parts[0] in INTERNAL_PREFIXES + ) + assert not leaked, ( + "Internal artifacts were published to the docs site:\n" + + "\n".join(leaked) + + "\n\nAdd the directory to `exclude_docs` in mkdocs.yml." + ) + + +@pytest.mark.skipif(shutil.which("mkdocs") is None, reason="mkdocs not installed") +def test_strict_build_succeeds(): + # --strict turns broken internal links and missing nav targets into errors, + # so this doubles as the link check for every page in the nav. + with tempfile.TemporaryDirectory() as tmp: + _build_site(Path(tmp) / "site") diff --git a/backend/common/tests/test_docs_environment_variables.py b/backend/common/tests/test_docs_environment_variables.py new file mode 100644 index 00000000..d2fa659d --- /dev/null +++ b/backend/common/tests/test_docs_environment_variables.py @@ -0,0 +1,94 @@ +"""The environment-variable reference must match what the code actually reads. + +The current bottlecrm.io quick-start tells self-hosters to set JWT_SIGNING_KEY. +No such variable is read anywhere in this codebase. A reader following that page +sets a variable that does nothing and believes their install is configured. +""" + +import re +from pathlib import Path + +import pytest +from django.conf import settings + +REPO_ROOT = Path(settings.BASE_DIR).parent +SETTINGS_PATH = Path(settings.BASE_DIR) / "crm" / "settings.py" +ENV_DOCKER_PATH = REPO_ROOT / ".env.docker" +COMPOSE_PATH = REPO_ROOT / "docker-compose.yml" +REFERENCE_PATH = REPO_ROOT / "docs" / "reference" / "environment-variables.md" + +# Matches os.environ.get("NAME") and os.getenv("NAME"), including calls whose +# argument sits on the following line. +SETTINGS_ENV_RE = re.compile( + r"os\.(?:environ\.get|getenv)\(\s*[\"']([A-Z][A-Z0-9_]*)[\"']" +) +# Matches the leading `NAME` cell of a markdown table row. +DOC_TABLE_ROW_RE = re.compile(r"^\|\s*`([A-Z][A-Z0-9_]*)`", re.MULTILINE) +# Matches NAME= in .env files and NAME: in compose environment blocks. +ENV_FILE_RE = re.compile(r"^\s*-?\s*([A-Z][A-Z0-9_]*)\s*[=:]", re.MULTILINE) + +# Read by settings.py but intentionally left out of the reference table. +UNDOCUMENTED_BY_DESIGN = { + "ENV_TYPE", # internal dev/prod switch; not something a self-hoster sets +} + + +def _documented_variables(text: str) -> set[str]: + return set(DOC_TABLE_ROW_RE.findall(text)) + + +def _variables_read_by_settings(text: str) -> set[str]: + return set(SETTINGS_ENV_RE.findall(text)) + + +def _variables_in_env_files() -> set[str]: + found: set[str] = set() + for path in (ENV_DOCKER_PATH, COMPOSE_PATH): + if path.exists(): + found |= set(ENV_FILE_RE.findall(path.read_text())) + return found + + +def test_documented_variables_parses_a_reference_table(): + text = "| Variable | Default |\n| --- | --- |\n| `SECRET_KEY` | none |\n" + assert _documented_variables(text) == {"SECRET_KEY"} + + +def test_variables_read_by_settings_handles_both_call_styles(): + text = 'a = os.environ.get("DBNAME", "x")\nb = os.getenv("DEBUG")\n' + assert _variables_read_by_settings(text) == {"DBNAME", "DEBUG"} + + +def test_variables_read_by_settings_handles_a_wrapped_call(): + text = 'EMAIL_BACKEND = os.environ.get(\n "EMAIL_BACKEND", "smtp"\n)\n' + assert _variables_read_by_settings(text) == {"EMAIL_BACKEND"} + + +@pytest.mark.skipif( + not REFERENCE_PATH.exists(), reason="reference page not written yet" +) +def test_no_documented_variable_is_read_nowhere(): + documented = _documented_variables(REFERENCE_PATH.read_text()) + known = _variables_read_by_settings(SETTINGS_PATH.read_text()) | _variables_in_env_files() + invented = sorted(documented - known) + assert not invented, ( + "These variables are documented but read nowhere in the codebase:\n " + + "\n ".join(invented) + + "\n\nThis is the JWT_SIGNING_KEY failure. Remove them, or point the " + "reference at the code that reads them." + ) + + +@pytest.mark.skipif( + not REFERENCE_PATH.exists(), reason="reference page not written yet" +) +def test_every_variable_settings_reads_is_documented(): + documented = _documented_variables(REFERENCE_PATH.read_text()) + read = _variables_read_by_settings(SETTINGS_PATH.read_text()) + missing = sorted(read - documented - UNDOCUMENTED_BY_DESIGN) + assert not missing, ( + "crm/settings.py reads these variables but the reference does not list them:\n " + + "\n ".join(missing) + + "\n\nAdd a row to docs/reference/environment-variables.md, or add the " + "name to UNDOCUMENTED_BY_DESIGN with a comment saying why." + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 65c88f77..91ed0586 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -94,6 +94,9 @@ dev = [ "pytest-django>=4.12.0", "pytest-cov>=7.1.0", ] +docs = [ + "mkdocs-material>=9.5.0", +] [tool.hatch.build.targets.wheel] packages = [ diff --git a/backend/scripts/generate_endpoint_index.py b/backend/scripts/generate_endpoint_index.py new file mode 100644 index 00000000..b983ddd1 --- /dev/null +++ b/backend/scripts/generate_endpoint_index.py @@ -0,0 +1,59 @@ +"""Render every route in the OpenAPI schema to docs/api/endpoint-index.md. + +The curated API pages are written by hand. This index is not: a hand-maintained +list of 350 operations would be wrong within a week. Re-run this script when +routes change: + + cd backend && uv run python scripts/generate_endpoint_index.py +""" + +import os +import sys +from pathlib import Path + +import django + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "crm.settings") +os.environ.setdefault("SECRET_KEY", "generate-endpoint-index-only") +os.environ.setdefault("ADMIN_EMAIL", "admin@localhost") +django.setup() + +from drf_spectacular.generators import SchemaGenerator # noqa: E402 + +OUTPUT = Path(__file__).resolve().parent.parent.parent / "docs" / "api" / "endpoint-index.md" +METHODS = ("get", "post", "put", "patch", "delete") + +HEADER = """# Endpoint index + +Every route the API exposes, generated from the OpenAPI schema. The curated +pages in this section explain the endpoints you are most likely to need; this +table exists so nothing is invisible. + +Interactive versions of the same schema are served by a running backend at +`/swagger-ui/` and `/api/schema/redoc/`, with the raw document at `/schema/`. + +!!! note + This page is generated by `backend/scripts/generate_endpoint_index.py`. + Edit that script, not this file. + +| Path | Methods | +| --- | --- | +""" + + +def main() -> None: + schema = SchemaGenerator().get_schema(request=None, public=True) + rows = [] + for path in sorted(schema["paths"]): + methods = sorted( + m.upper() for m in schema["paths"][path] if m in METHODS + ) + if methods: + rows.append(f"| `{path}` | {', '.join(methods)} |") + OUTPUT.write_text(HEADER + "\n".join(rows) + "\n") + print(f"wrote {OUTPUT} — {len(rows)} paths") + + +if __name__ == "__main__": + main() diff --git a/backend/uv.lock b/backend/uv.lock index 8d9262ec..89bd7ea4 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version < '3.13'", @@ -13,9 +14,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "caio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342 } +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446 }, + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, ] [[package]] @@ -25,18 +26,18 @@ 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 } +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 }, + { 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 = "annotated-types" version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] @@ -47,27 +48,27 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176 } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] name = "asgiref" version = "3.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378 } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478 }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -78,9 +79,31 @@ dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511 } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548 }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, ] [[package]] @@ -111,18 +134,18 @@ dev = [ name = "beartype" version = "0.22.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658 }, + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] [[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 } +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 }, + { 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]] @@ -134,9 +157,9 @@ dependencies = [ { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/05/23e1aa8c9e4b0399a61e7fd65c4f9cc0625121f24760e37471f776404abb/boto3-1.43.56.tar.gz", hash = "sha256:57c90df9fb026f2e6ae22530861198130203733c5c9ec4e5cca3a4037f5a8db4", size = 112673 } +sdist = { url = "https://files.pythonhosted.org/packages/53/05/23e1aa8c9e4b0399a61e7fd65c4f9cc0625121f24760e37471f776404abb/boto3-1.43.56.tar.gz", hash = "sha256:57c90df9fb026f2e6ae22530861198130203733c5c9ec4e5cca3a4037f5a8db4", size = 112673, upload-time = "2026-07-24T19:31:48.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/57/3a960c9f581c00f2a591901b46e035ff79ab3956d16607f12306b3b8d483/boto3-1.43.56-py3-none-any.whl", hash = "sha256:feb699d4ab241ef5c1b80bb58277be2aaad365cd4b672d7817e0bc59ee45131b", size = 140026 }, + { url = "https://files.pythonhosted.org/packages/b8/57/3a960c9f581c00f2a591901b46e035ff79ab3956d16607f12306b3b8d483/boto3-1.43.56-py3-none-any.whl", hash = "sha256:feb699d4ab241ef5c1b80bb58277be2aaad365cd4b672d7817e0bc59ee45131b", size = 140026, upload-time = "2026-07-24T19:31:47.155Z" }, ] [[package]] @@ -148,47 +171,47 @@ dependencies = [ { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/cc/7f84a5d3071fe878380e9f610ab36ca87b8cbbc4aa81ba2727f90e1f3ea3/botocore-1.43.56.tar.gz", hash = "sha256:6c01f85f0ff9863076f4c761e74ee3aa96c5ccc1ad09fc1efd62ef8f2d22bf57", size = 15733117 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/cc/7f84a5d3071fe878380e9f610ab36ca87b8cbbc4aa81ba2727f90e1f3ea3/botocore-1.43.56.tar.gz", hash = "sha256:6c01f85f0ff9863076f4c761e74ee3aa96c5ccc1ad09fc1efd62ef8f2d22bf57", size = 15733117, upload-time = "2026-07-24T19:31:38.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/cd/86fe9e659e9699f62f8dd5ecd8c6725474334b23cab8aa71d82b5f56f1a4/botocore-1.43.56-py3-none-any.whl", hash = "sha256:aafc741f1b10f6fd63253eaf6ea029680c1ff436d87e1b8969d62aefa0c76976", size = 15418773 }, + { url = "https://files.pythonhosted.org/packages/5c/cd/86fe9e659e9699f62f8dd5ecd8c6725474334b23cab8aa71d82b5f56f1a4/botocore-1.43.56-py3-none-any.whl", hash = "sha256:aafc741f1b10f6fd63253eaf6ea029680c1ff436d87e1b8969d62aefa0c76976", size = 15418773, upload-time = "2026-07-24T19:31:34.758Z" }, ] [[package]] name = "brotli" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543 }, - { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288 }, - { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071 }, - { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913 }, - { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762 }, - { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494 }, - { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302 }, - { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913 }, - { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362 }, - { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115 }, - { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523 }, - { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289 }, - { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076 }, - { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880 }, - { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737 }, - { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440 }, - { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313 }, - { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945 }, - { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368 }, - { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116 }, - { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080 }, - { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453 }, - { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168 }, - { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098 }, - { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861 }, - { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594 }, - { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455 }, - { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164 }, - { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280 }, - { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639 }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, ] [[package]] @@ -198,48 +221,48 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046 }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126 }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983 }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047 }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652 }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608 }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257 }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838 }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337 }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026 }, + { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, ] [[package]] name = "cachetools" version = "7.1.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572 } +sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954 }, + { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, ] [[package]] name = "caio" version = "0.9.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781 } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983 }, - { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012 }, - { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502 }, - { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200 }, - { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979 }, - { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900 }, - { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523 }, - { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243 }, - { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978 }, - { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832 }, - { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565 }, - { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071 }, - { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087 }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] [[package]] @@ -249,9 +272,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096 } +sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611 }, + { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, ] [[package]] @@ -269,18 +292,18 @@ dependencies = [ { name = "tzlocal" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243 } +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 }, + { 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.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] @@ -290,143 +313,143 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036 } +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829 }, - { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728 }, - { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815 }, - { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429 }, - { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315 }, - { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859 }, - { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844 }, - { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287 }, - { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681 }, - { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269 }, - { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881 }, - { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088 }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331 }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966 }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795 }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746 }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747 }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392 }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285 }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801 }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808 }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241 }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588 }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248 }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717 }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114 }, - { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142 }, - { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819 }, - { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965 }, - { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952 }, - { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353 }, - { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051 }, - { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630 }, - { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593 }, - { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146 }, - { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240 }, - { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723 }, - { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937 }, - { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001 }, - { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538 }, - { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230 }, - { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899 }, - { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652 }, - { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755 }, - { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933 }, - { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749 }, - { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703 }, - { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857 }, - { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065 }, - { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404 }, - { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121 }, - { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820 }, - { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936 }, - { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045 }, - { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342 }, - { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073 }, - { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551 }, - { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649 }, - { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203 }, - { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263 }, - { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696 }, - { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914 }, - { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004 }, - { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378 }, - { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319 }, - { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904 }, - { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554 }, - { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795 }, - { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843 }, - { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773 }, - { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719 }, - { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760 }, - { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769 }, - { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405 }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300 }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802 }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171 }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075 }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256 }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784 }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928 }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489 }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267 }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030 }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185 }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557 }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665 }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688 }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982 }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460 }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003 }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149 }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901 }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176 }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356 }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614 }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991 }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947 }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594 }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253 }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898 }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718 }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519 }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143 }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742 }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191 }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328 }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406 }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157 }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095 }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796 }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334 }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848 }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022 }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590 }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584 }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224 }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667 }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179 }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372 }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222 }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958 }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580 }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620 }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037 }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538 }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -434,11 +457,11 @@ name = "click" version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -448,9 +471,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089 } +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 }, + { 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]] @@ -460,9 +483,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343 } +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 }, + { 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]] @@ -473,87 +496,87 @@ dependencies = [ { name = "click" }, { 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 } +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 }, + { 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]] name = "coverage" version = "7.15.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741 } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587 }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943 }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450 }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187 }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301 }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562 }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841 }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221 }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366 }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434 }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935 }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807 }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641 }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172 }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556 }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606 }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982 }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972 }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569 }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806 }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936 }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178 }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934 }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898 }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056 }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718 }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490 }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647 }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190 }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583 }, - { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650 }, - { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988 }, - { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029 }, - { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536 }, - { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881 }, - { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196 }, - { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036 }, - { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887 }, - { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852 }, - { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128 }, - { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668 }, - { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325 }, - { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844 }, - { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331 }, - { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760 }, - { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384 }, - { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647 }, - { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013 }, - { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135 }, - { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555 }, - { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674 }, - { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101 }, - { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007 }, - { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611 }, - { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344 }, - { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456 }, - { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771 }, - { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151 }, - { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981 }, - { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294 }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375 }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] [[package]] @@ -563,47 +586,47 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345 } +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100 }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978 }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422 }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503 }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779 }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683 }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874 }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283 }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844 }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290 }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612 }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804 }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026 }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892 }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835 }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239 }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593 }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961 }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145 }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719 }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209 }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285 }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441 }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869 }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948 }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153 }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947 }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429 }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968 }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758 }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863 }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983 }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173 }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298 }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338 }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650 }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820 }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968 }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547 }, + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] [[package]] @@ -614,9 +637,9 @@ dependencies = [ { name = "tinycss2" }, { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453 }, + { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, ] [[package]] @@ -629,18 +652,18 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/98/ca72a91d5c25a2ae1baf19d27b31f12288cb9d8a3168b65b3cd54d40d277/cyclopts-4.22.2.tar.gz", hash = "sha256:0721e90e7209885e78f7637cfba255c12e89206b633e35d185305e349ba20ecd", size = 194511 } +sdist = { url = "https://files.pythonhosted.org/packages/69/98/ca72a91d5c25a2ae1baf19d27b31f12288cb9d8a3168b65b3cd54d40d277/cyclopts-4.22.2.tar.gz", hash = "sha256:0721e90e7209885e78f7637cfba255c12e89206b633e35d185305e349ba20ecd", size = 194511, upload-time = "2026-07-24T20:53:08.739Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/75/a11bfb5045e58b4ef69337b848985918e829fe9a90572a761d17c1a992f5/cyclopts-4.22.2-py3-none-any.whl", hash = "sha256:9c2cdf6a621886cd0af631a67437eb7d0084f33f9e8fba2d2562a1aecf75f2ff", size = 233906 }, + { url = "https://files.pythonhosted.org/packages/bd/75/a11bfb5045e58b4ef69337b848985918e829fe9a90572a761d17c1a992f5/cyclopts-4.22.2-py3-none-any.whl", hash = "sha256:9c2cdf6a621886cd0af631a67437eb7d0084f33f9e8fba2d2562a1aecf75f2ff", size = 233906, upload-time = "2026-07-24T20:53:07.064Z" }, ] [[package]] name = "disposable-email-domains" version = "0.0.231" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/09/1b7e1283f6b8f03188b7aa7f29d76339818650cb9318093127b61b57ce49/disposable_email_domains-0.0.231.tar.gz", hash = "sha256:584e3dcdc38b971e286b493c4047acd64734bc7d7404aee5f9e8ba9cdc4a3f2d", size = 99682 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/09/1b7e1283f6b8f03188b7aa7f29d76339818650cb9318093127b61b57ce49/disposable_email_domains-0.0.231.tar.gz", hash = "sha256:584e3dcdc38b971e286b493c4047acd64734bc7d7404aee5f9e8ba9cdc4a3f2d", size = 99682, upload-time = "2026-07-26T11:15:58.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/13/c0de5aa1b85a1ff4afa4a6c8c704d2eb9ca462ef10bc9f8a1f006293fe03/disposable_email_domains-0.0.231-py3-none-any.whl", hash = "sha256:f978a9df0ac8d8870fcfcc33165b711179b67602a0cbd77a8cc93bb247dfa75b", size = 51268 }, + { url = "https://files.pythonhosted.org/packages/52/13/c0de5aa1b85a1ff4afa4a6c8c704d2eb9ca462ef10bc9f8a1f006293fe03/disposable_email_domains-0.0.231-py3-none-any.whl", hash = "sha256:f978a9df0ac8d8870fcfcc33165b711179b67602a0cbd77a8cc93bb247dfa75b", size = 51268, upload-time = "2026-07-26T11:15:57.318Z" }, ] [[package]] @@ -652,9 +675,9 @@ dependencies = [ { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299 } +sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344 }, + { url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344, upload-time = "2026-07-07T13:51:20.007Z" }, ] [[package]] @@ -665,9 +688,9 @@ dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458 } +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809 }, + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] [[package]] @@ -713,6 +736,9 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-django" }, ] +docs = [ + { name = "mkdocs-material" }, +] [package.metadata] requires-dist = [ @@ -743,6 +769,7 @@ requires-dist = [ { name = "weasyprint", specifier = ">=69.0" }, { name = "whitenoise", specifier = ">=6.12.0" }, ] +provides-extras = ["mcp"] [package.metadata.requires-dev] dev = [ @@ -750,6 +777,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-django", specifier = ">=4.12.0" }, ] +docs = [{ name = "mkdocs-material", specifier = ">=9.5.0" }] [[package]] name = "django-crum" @@ -758,9 +786,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/1d/c56588f67130aeef8828e47535e8551337d2ae02f91f1414da61bc5e49fb/django-crum-0.7.9.tar.gz", hash = "sha256:65e9bc0f070a663fafc4d9e357f45fd4e6f01838b20a9e2fb7670f5706754288", size = 5168 } +sdist = { url = "https://files.pythonhosted.org/packages/34/1d/c56588f67130aeef8828e47535e8551337d2ae02f91f1414da61bc5e49fb/django-crum-0.7.9.tar.gz", hash = "sha256:65e9bc0f070a663fafc4d9e357f45fd4e6f01838b20a9e2fb7670f5706754288", size = 5168, upload-time = "2020-11-10T17:15:35.124Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/f9/8fad70a3bd011a6be7c5c6067278f006a25341eb39d901fbda307e26804c/django_crum-0.7.9-py2.py3-none-any.whl", hash = "sha256:037cc8b822975bb1d41cd24269b59a512cc77448fadc3f34ab9a17b229b4b471", size = 4714 }, + { url = "https://files.pythonhosted.org/packages/cd/f9/8fad70a3bd011a6be7c5c6067278f006a25341eb39d901fbda307e26804c/django_crum-0.7.9-py2.py3-none-any.whl", hash = "sha256:037cc8b822975bb1d41cd24269b59a512cc77448fadc3f34ab9a17b229b4b471", size = 4714, upload-time = "2020-11-10T17:15:33.781Z" }, ] [[package]] @@ -771,9 +799,9 @@ dependencies = [ { name = "boto3" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/25/25838da8e213c9f125b26a25360f0bb8ac57f07c24977451f3e7a0d63ddd/django_ses-4.7.2.tar.gz", hash = "sha256:a36f2af0e4ce060bf36053ed4c94feac1703ea3351e677c6f6421abd01433a35", size = 71828 } +sdist = { url = "https://files.pythonhosted.org/packages/7f/25/25838da8e213c9f125b26a25360f0bb8ac57f07c24977451f3e7a0d63ddd/django_ses-4.7.2.tar.gz", hash = "sha256:a36f2af0e4ce060bf36053ed4c94feac1703ea3351e677c6f6421abd01433a35", size = 71828, upload-time = "2026-02-20T19:22:35.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/f2/15d4bd54bd01e68e8a116e0b66243c91cb62a3fa0d780a39d2af6654e8ae/django_ses-4.7.2-py3-none-any.whl", hash = "sha256:f3db567fb6f43c01d7d890f5c991e1ebbfa48220de0be24d497ba6332004abcb", size = 37796 }, + { url = "https://files.pythonhosted.org/packages/84/f2/15d4bd54bd01e68e8a116e0b66243c91cb62a3fa0d780a39d2af6654e8ae/django_ses-4.7.2-py3-none-any.whl", hash = "sha256:f3db567fb6f43c01d7d890f5c991e1ebbfa48220de0be24d497ba6332004abcb", size = 37796, upload-time = "2026-02-20T19:22:32.819Z" }, ] [[package]] @@ -783,9 +811,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095 }, + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, ] [[package]] @@ -795,9 +823,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844 }, + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, ] [[package]] @@ -809,27 +837,27 @@ dependencies = [ { name = "djangorestframework" }, { name = "pyjwt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265 } +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674 }, + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, ] [[package]] name = "dnspython" version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] [[package]] name = "docstring-parser" version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484 }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] @@ -844,9 +872,9 @@ dependencies = [ { name = "pyyaml" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/43/41d25039a6a53545420ebc98eb9f877ec9fe30c7bd03fefabcaf9b953af7/drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9", size = 252311 } +sdist = { url = "https://files.pythonhosted.org/packages/50/43/41d25039a6a53545420ebc98eb9f877ec9fe30c7bd03fefabcaf9b953af7/drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9", size = 252311, upload-time = "2026-07-06T11:29:46.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150 }, + { url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150, upload-time = "2026-07-06T11:29:45.12Z" }, ] [[package]] @@ -857,9 +885,9 @@ dependencies = [ { name = "dnspython" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] [[package]] @@ -869,9 +897,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -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]] @@ -879,11 +907,11 @@ name = "faker" version = "40.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "platform_system == 'Windows'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/d2/026af1e002bbc6df534d1f8262b18ec79a974f928e9290bfbfdfe7c7b2af/faker-40.36.0.tar.gz", hash = "sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308", size = 2025903 } +sdist = { url = "https://files.pythonhosted.org/packages/98/d2/026af1e002bbc6df534d1f8262b18ec79a974f928e9290bfbfdfe7c7b2af/faker-40.36.0.tar.gz", hash = "sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308", size = 2025903, upload-time = "2026-07-24T21:11:33.088Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/9a/b947ed175ce9a0dcb070ccf3607f0ce8720cfb5ed1a36166a150b2acd5af/faker-40.36.0-py3-none-any.whl", hash = "sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62", size = 2062829 }, + { url = "https://files.pythonhosted.org/packages/50/9a/b947ed175ce9a0dcb070ccf3607f0ce8720cfb5ed1a36166a150b2acd5af/faker-40.36.0-py3-none-any.whl", hash = "sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62", size = 2062829, upload-time = "2026-07-24T21:11:31.37Z" }, ] [[package]] @@ -893,9 +921,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458 } +sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019 }, + { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" }, ] [[package]] @@ -910,9 +938,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203 } +sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158 }, + { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, ] [package.optional-dependencies] @@ -953,41 +981,41 @@ server = [ name = "fonttools" version = "4.63.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189 } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131 }, - { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704 }, - { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298 }, - { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800 }, - { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666 }, - { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598 }, - { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575 }, - { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211 }, - { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062 }, - { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594 }, - { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840 }, - { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801 }, - { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009 }, - { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892 }, - { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313 }, - { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299 }, - { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338 }, - { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661 }, - { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526 }, - { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946 }, - { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489 }, - { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870 }, - { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026 }, - { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454 }, - { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152 }, - { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809 }, - { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649 }, - { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147 }, - { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237 }, - { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933 }, - { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326 }, - { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829 }, - { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562 }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] [package.optional-dependencies] @@ -997,6 +1025,18 @@ woff = [ { name = "zopfli" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "google-auth" version = "2.56.2" @@ -1005,18 +1045,18 @@ dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588 }, + { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" }, ] [[package]] name = "griffelib" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234 } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560 }, + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] [[package]] @@ -1026,18 +1066,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286 } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009 }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[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]] @@ -1048,45 +1088,45 @@ 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]] name = "httptools" version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342 } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247 }, - { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064 }, - { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851 }, - { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842 }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238 }, - { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567 }, - { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918 }, - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148 }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368 }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447 }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448 }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460 }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312 }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117 }, - { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183 }, - { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079 }, - { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596 }, - { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865 }, - { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189 }, - { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610 }, - { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705 }, - { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023 }, - { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405 }, - { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497 }, - { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585 }, - { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297 }, - { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535 }, - { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209 }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] @@ -1099,45 +1139,45 @@ 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 = "httpx-sse" version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "inflection" version = "0.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091 } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454 }, + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +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]] @@ -1147,18 +1187,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, ] [[package]] name = "jaraco-context" version = "6.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801 } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871 }, + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, ] [[package]] @@ -1168,27 +1208,39 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677 }, + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, ] [[package]] name = "jeepney" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "jmespath" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] [[package]] @@ -1198,18 +1250,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, ] [[package]] name = "jsonref" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425 }, + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, ] [[package]] @@ -1222,9 +1274,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -1237,9 +1289,9 @@ dependencies = [ { name = "pyyaml" }, { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597 } +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077 }, + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, ] [[package]] @@ -1249,9 +1301,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] @@ -1266,9 +1318,9 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516 } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] [[package]] @@ -1281,9 +1333,18 @@ dependencies = [ { name = "tzdata" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594 } +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 }, + { 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 = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -1293,9 +1354,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -1318,27 +1442,105 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501 } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620 }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] name = "more-itertools" version = "11.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772 } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226 }, + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] [[package]] @@ -1348,9 +1550,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] [[package]] @@ -1360,116 +1562,134 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018 }, + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] [[package]] name = "pathable" version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970 } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983 }, + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pillow" version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969 }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323 }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838 }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830 }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383 }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934 }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684 }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137 }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267 }, - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684 }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487 }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433 }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889 }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109 }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736 }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129 }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562 }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439 }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287 }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691 }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185 }, - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736 }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435 }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262 }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344 }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131 }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757 }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962 }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171 }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116 }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209 }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707 }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995 }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503 }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956 }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855 }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642 }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281 }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716 }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125 }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939 }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506 }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063 }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549 }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331 }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370 }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147 }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659 }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439 }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577 }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394 }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375 }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048 }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006 }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509 }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167 }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237 }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047 }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440 }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895 }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384 }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537 }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491 }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] name = "platformdirs" version = "4.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953 } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247 }, + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +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]] @@ -1479,50 +1699,50 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288 }, + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] [[package]] name = "psycopg2-binary" version = "2.9.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459 }, - { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936 }, - { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676 }, - { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917 }, - { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843 }, - { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556 }, - { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714 }, - { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154 }, - { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882 }, - { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298 }, - { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230 }, - { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419 }, - { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990 }, - { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696 }, - { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982 }, - { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867 }, - { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578 }, - { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816 }, - { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307 }, - { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968 }, - { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369 }, - { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347 }, - { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428 }, - { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184 }, - { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157 }, - { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970 }, - { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175 }, - { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658 }, - { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251 }, - { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810 }, - { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977 }, - { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466 }, - { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762 }, + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] [[package]] @@ -1533,9 +1753,9 @@ dependencies = [ { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005 }, + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, ] [package.optional-dependencies] @@ -1554,9 +1774,9 @@ memory = [ name = "pyasn1" version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262 } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410 }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -1566,18 +1786,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -1590,9 +1810,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -1607,76 +1827,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589 }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552 }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984 }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417 }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] [[package]] @@ -1688,36 +1904,36 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700 } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715 }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] name = "pydyf" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716 } +sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028 }, + { url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -1725,22 +1941,35 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pymdown-extensions" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, +] + [[package]] name = "pyperclip" version = "1.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063 }, + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] [[package]] name = "pyphen" version = "0.17.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470 } +sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358 }, + { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, ] [[package]] @@ -1754,9 +1983,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -1768,9 +1997,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1780,9 +2009,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -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]] @@ -1792,36 +2021,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { 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]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] name = "pytz" version = "2026.3.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283 }, + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, ] [[package]] @@ -1829,82 +2058,94 @@ name = "pywin32" version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877 }, - { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841 }, - { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901 }, - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184 }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298 }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640 }, - { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928 }, - { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157 }, - { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598 }, - { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159 }, - { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293 }, - { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337 }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, ] [[package]] name = "pywin32-ctypes" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] name = "redis" version = "8.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406 }, + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, ] [[package]] @@ -1916,9 +2157,9 @@ dependencies = [ { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] @@ -1931,9 +2172,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -1944,9 +2185,9 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -1957,105 +2198,105 @@ dependencies = [ { name = "pygments" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987 }, + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, ] [[package]] name = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691 }, - { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542 }, - { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180 }, - { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067 }, - { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509 }, - { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754 }, - { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189 }, - { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750 }, - { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576 }, - { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807 }, - { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187 }, - { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030 }, - { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185 }, - { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394 }, - { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753 }, - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 }, - { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510 }, - { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495 }, - { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454 }, - { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583 }, - { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919 }, - { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725 }, - { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255 }, - { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060 }, - { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960 }, - { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356 }, - { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319 }, - { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508 }, - { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504 }, - { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380 }, - { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976 }, - { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840 }, - { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282 }, - { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403 }, - { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055 }, - { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419 }, - { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848 }, - { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369 }, - { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673 }, - { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500 }, - { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978 }, - { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350 }, - { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486 }, - { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068 }, - { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600 }, - { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726 }, - { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587 }, - { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585 }, - { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479 }, - { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418 }, - { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123 }, - { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351 }, - { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827 }, - { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966 }, - { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680 }, - { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853 }, - { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715 }, - { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864 }, - { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430 }, - { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877 }, - { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933 }, - { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274 }, - { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763 }, - { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467 }, - { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689 }, - { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340 }, - { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179 }, - { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993 }, - { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909 }, - { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584 }, - { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357 }, - { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533 }, - { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204 }, - { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719 }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, ] [[package]] @@ -2065,9 +2306,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592 } +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216 }, + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, ] [[package]] @@ -2078,9 +2319,9 @@ dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] @@ -2091,27 +2332,27 @@ dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543 } +sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555 }, + { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" }, ] [[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 = "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]] @@ -2122,9 +2363,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516 }, + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, ] [[package]] @@ -2135,9 +2376,9 @@ dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632 }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -2147,9 +2388,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404 }, + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] [[package]] @@ -2159,18 +2400,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686 }, + { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -2180,18 +2421,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674 } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] @@ -2199,38 +2440,38 @@ name = "tzlocal" version = "5.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "platform_system == 'Windows'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170 } +sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115 }, + { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, ] [[package]] name = "uncalled-for" version = "0.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444 }, + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, ] [[package]] name = "uritemplate" version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, ] [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -2241,9 +2482,9 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219 }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [package.optional-dependencies] @@ -2260,41 +2501,65 @@ standard = [ name = "uvloop" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769 }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413 }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307 }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970 }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343 }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067 }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423 }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437 }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101 }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360 }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790 }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783 }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548 }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065 }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384 }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730 }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[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 } +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 }, + { 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 = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] @@ -2304,92 +2569,92 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115 }, - { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659 }, - { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207 }, - { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273 }, - { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927 }, - { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476 }, - { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650 }, - { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398 }, - { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140 }, - { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259 }, - { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859 }, - { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480 }, - { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718 }, - { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026 }, - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730 }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842 }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989 }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978 }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248 }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847 }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974 }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782 }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182 }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841 }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028 }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183 }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059 }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186 }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031 }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205 }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892 }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867 }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217 }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458 }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707 }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663 }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537 }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194 }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194 }, - { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205 }, - { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508 }, - { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448 }, - { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605 }, - { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757 }, - { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672 }, - { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197 }, - { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181 }, - { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109 }, - { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653 }, - { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838 }, - { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108 }, - { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441 }, - { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684 }, - { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857 }, - { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413 }, - { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409 }, - { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827 }, - { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104 }, - { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360 }, - { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644 }, - { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771 }, - { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494 }, - { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383 }, - { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093 }, - { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109 }, - { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167 }, - { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372 }, - { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596 }, - { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869 }, - { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641 }, - { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444 }, - { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593 }, - { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096 }, - { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638 }, - { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684 }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] [[package]] name = "wcwidth" version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253 } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166 }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] [[package]] @@ -2406,118 +2671,118 @@ dependencies = [ { name = "tinycss2" }, { name = "tinyhtml5" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834 } +sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872 }, + { url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" }, ] [[package]] name = "webencodings" version = "0.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] [[package]] name = "websockets" version = "16.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204 } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600 }, - { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272 }, - { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542 }, - { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137 }, - { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374 }, - { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155 }, - { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011 }, - { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766 }, - { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173 }, - { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809 }, - { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412 }, - { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290 }, - { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844 }, - { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823 }, - { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102 }, - { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943 }, - { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243 }, - { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587 }, - { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272 }, - { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530 }, - { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197 }, - { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433 }, - { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868 }, - { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059 }, - { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814 }, - { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229 }, - { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874 }, - { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469 }, - { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347 }, - { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903 }, - { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855 }, - { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140 }, - { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928 }, - { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242 }, - { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635 }, - { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320 }, - { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544 }, - { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270 }, - { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509 }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882 }, - { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114 }, - { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861 }, - { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286 }, - { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935 }, - { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444 }, - { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409 }, - { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958 }, - { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911 }, - { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204 }, - { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603 }, - { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948 }, - { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963 }, - { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698 }, - { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561 }, - { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732 }, - { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872 }, - { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305 }, - { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033 }, - { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748 }, - { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285 }, - { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777 }, - { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682 }, - { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377 }, - { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148 }, - { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578 }, - { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729 }, - { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072 }, - { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814 }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, ] [[package]] name = "whitenoise" version = "6.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302 }, + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, ] [[package]] name = "zopfli" version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156 } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492 }, - { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354 }, - { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436 }, - { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931 }, - { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132 }, - { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715 }, - { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550 }, - { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343 }, + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, ] diff --git a/docs/api/.endpoint-check-allowlist b/docs/api/.endpoint-check-allowlist new file mode 100644 index 00000000..2ef74084 --- /dev/null +++ b/docs/api/.endpoint-check-allowlist @@ -0,0 +1,4 @@ +# Endpoints documented in docs/api/ that deliberately do not appear in the +# generated OpenAPI schema. One "METHOD /path/" per line, each with a comment +# explaining why. An empty allowlist is the expected steady state — if you are +# adding a line here, first check the endpoint is not simply misspelled. diff --git a/docs/api/accounts.md b/docs/api/accounts.md new file mode 100644 index 00000000..3e72b7ee --- /dev/null +++ b/docs/api/accounts.md @@ -0,0 +1,160 @@ +# Accounts + +Routes in `backend/accounts/urls.py`, all served by `backend/accounts/views.py`. See +[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than +repeated here, and [Errors](errors.md) for the 404-vs-403 principle. + +## List accounts + +`GET /api/accounts/` (`AccountsListView.get`, `backend/accounts/views.py:312-314`) splits the result +into active and inactive accounts rather than open/closed — `Account` has no status field, only +`is_active` (`accounts/views.py:237-238`): + +```json +{ + "per_page": 10, + "page_number": [1], + "active_accounts": {"offset": 10, "open_accounts": [ { "...": "AccountSerializer" } ], "open_accounts_count": 37}, + "closed_accounts": {"offset": null, "close_accounts": [ { "...": "..." } ], "close_accounts_count": 4}, + "contacts": [{"id": "...", "first_name": "..."}], + "teams": [{"...": "..."}], + "countries": [["GB", "United Kingdom"]], + "industries": [["SOFTWARE", "SOFTWARE"]], + "tags": [{"...": "..."}], + "users": [{"id": "...", "user__email": "..."}], + "leads": [{"...": "LeadSerializer"}], + "status": ["active", "inactive"] +} +``` + +A non-admin caller only sees accounts they created or are assigned to +(`accounts/views.py:195-199`). Filters: `name`, `city`, `industry` (all `icontains`); `tags`, +`assigned_to` (repeatable, `__id__in`); `search` (matches `name` only — unlike leads' `search`, which +ORs across four fields); `created_at__gte`/`created_at__lte`; `cf_` (`accounts/views.py:201-233`). + +Every account in the list (and the detail view below) carries a **`rollups`** object — +`won_amount`, `won_count`, `open_pipeline`, `open_deal_count`, `overdue_amount`, `open_tickets`, +`first_won_on` — computed as correlated subqueries over that account's `Opportunity`, `Invoice` and +`Case` rows, never stored (`annotate_rollups`, `accounts/views.py:135-182`). `rollups` is `null`, +not zero-filled, on any serialization that skips the annotation (`AccountSerializer.get_rollups`, +`accounts/serializer.py:41-54`) — both the list and detail endpoints here apply it, so in practice you +will always see it populated through this page's endpoints. + +## Create an account + +`POST /api/accounts/` (`AccountsListView.post`, `accounts/views.py:322-400`) is validated by +`AccountCreateSerializer` (`accounts/serializer.py:189-257`); see [Fields](#fields). On success +(`200`): + +```json +{"error": false, "message": "Account Created Successfully", "id": ""} +``` + +Two validators worth knowing: **`name` must be unique per org, case-insensitively** +(`validate_name`, `accounts/serializer.py:199-210`) — `{"error": true, "errors": {"name": ["Account already exists with this name"]}}` +on a duplicate; and **`annual_revenue` cannot be negative** +(`validate_annual_revenue`, `:212-224`), rejected as `400` rather than reaching the database's own +`account_revenue_non_negative` check constraint as an unhandled `500`. + +`contacts`, `tags`, `teams` and `assigned_to` are not part of the serializer — the view resolves them +from the request body via the shared `handle_m2m_assignment` / `get_or_create_tags` helpers +(`common/utils.py:437-510`), each scoped to `org=request.profile.org` +(`accounts/views.py:347-368`). An `account_attachment` multipart file is accepted the same way. + +## Retrieve, update, delete + +`GET /api/accounts/{id}/` (`AccountDetailView.get`, `accounts/views.py:604-704`) scopes the lookup to +the caller's org and treats a malformed id the same as a missing one — both `404` +(`get_object`, `:407-421`, catching `UUIDField`'s `ValidationError` that `get_object_or_404` alone does +not). `assert_account_access` (`:423-445`) then requires the caller to be an admin, the account's +creator, or one of its assignees, or the request is refused with `403`. The response nests the record +under `account_obj`, and also carries `contacts` (`Account.contacts`, not the `Contact.account` +foreign key — see [Contacts: linked accounts](contacts.md#fields)), `opportunity_list`, `cases`, +`tasks`, `invoices`, `emails`, `comments`, `attachments`, `teams`, `users`, `users_mention`, +`comment_permission`, `leads`, `countries`, `currencies`, `case_types`, `case_priority`, +`case_status`, `custom_field_definitions`, a fixed, two-value `status: ["open", "close"]` +(`accounts/views.py:692` — a static list, unrelated to `Account.is_active`, which is what actually +drives [List accounts](#list-accounts)'s active/inactive split), and two keys worth flagging +explicitly: + +```json +{ + "stages": [["PROSPECTING", "Prospecting"], ["QUALIFICATION", "Qualification"], ["CLOSED_WON", "Closed Won"]], + "sources": [["NONE", "NONE"], ["CALL", "CALL"], ["WEBSITE", "WEBSITE"], ["OTHER", "OTHER"]] +} +``` + +(`accounts/views.py:673-674`.) These are `Opportunity`'s `STAGES` and `SOURCES` enums — surfaced here +because the account detail page is where an "add opportunity against this account" form lives, not +because an account has stages or sources of its own. `sources` on this endpoint is the *uppercase* +vocabulary that includes `WEBSITE`; it is not `Lead.source`'s `LEAD_SOURCE` — see +[Opportunities: Stages](opportunities.md#stages) for the distinction, and +[Leads](leads.md#list-leads) for why conflating the two is a real failure mode with this API. + +`PUT /api/accounts/{id}/` (`:453-575`) and `PATCH /api/accounts/{id}/` (`:763-871`) both call +`assert_account_access` **before** validating the request body — deliberately, so an unauthorized +caller learns they're forbidden before learning whether their payload was well-formed +(`accounts/views.py:456-459`). Both accept the same `contacts`/`tags`/`teams`/`assigned_to` M2M +handling and `account_attachment` upload as create. Success: +`{"error": false, "message": "Account Updated Successfully"}`. + +`DELETE /api/accounts/{id}/` (`:582-597`) allows an admin or the account's own creator +(`request.profile.user_id != self.object.created_by_id` is the rejection condition, `:584-591`) — +anyone else gets `403`. + +## Comments and attachments + +`POST /api/accounts/{id}/` (`AccountDetailView.post`, `accounts/views.py:712-755`) adds a comment +and/or an `account_attachment` file to an existing account, gated by the same +`assert_account_access` rule as `GET`/`PUT`/`PATCH` (`:719`). The response echoes `account_obj`, +`attachments` and `comments`. + +`PUT` / `PATCH` / `DELETE /api/accounts/comment/{id}/` (`AccountCommentView`, +`accounts/views.py:874-968`) is restricted to an admin or the comment's own author +(`request.profile == obj.commented_by` — correct, since `commented_by` is a `Profile` foreign key). + +`DELETE /api/accounts/attachment/{id}/` (`AccountAttachmentView.delete`, `:971-1010`) has its lookup +correctly scoped to the caller's org +(`get_object_or_404(self.model, pk=pk, org=self.request.profile.org)`, `:989-991`, with malformed ids +also mapped to `404`) and its ownership check compares the right types +(`request.profile.user_id == self.object.created_by_id`, `:997`). That makes accounts one of five apps +in this API whose attachment-delete lookup is org-scoped — the same is true of contacts +(`contacts/views.py:1046`), cases (`cases/views.py:1119-1121`), tasks +(`tasks/views/task_views.py:975-977`) and invoices (`invoices/api_views.py:1821`). Only **leads and +opportunity** still have the unscoped version — see +[Leads: Comments and attachments](leads.md#comments-and-attachments) and +[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks). +(One stale comment in `cases/views.py` claims tasks is still broken — it isn't; the fix landed and the +comment didn't get updated. Verified against `tasks/views/task_views.py:975-987` directly rather than +taken on the comment's word.) + +## Fields + +`AccountCreateSerializer.Meta.fields` (`accounts/serializer.py:226-249`) is what +`POST /api/accounts/`, `PUT /api/accounts/{id}/` and `PATCH /api/accounts/{id}/` accept. Only `name` +is required at the model level (`accounts/models.py:26`, no `blank=True`); every other field on this +serializer has `blank=True, null=True` or a default. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `name` | string | **required** | Unique per org, case-insensitive | +| `email` | string | optional | | +| `phone` | string | optional | | +| `website` | string | optional | `URLField` — validated as a URL, unlike `Lead.website` (see [Leads](leads.md#fields)) | +| `industry` | one of `INDCHOICES` | optional | e.g. `SOFTWARE` | +| `number_of_employees` | integer | optional | Non-negative (`PositiveIntegerField`) | +| `annual_revenue` | decimal | optional | Non-negative — checked in the serializer, not just the DB | +| `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted and `annual_revenue` is set | +| `address_line`, `city`, `state`, `postcode` | string | optional | | +| `country` | one of `COUNTRIES` | optional | | +| `description` | text | optional | | +| `is_active` | boolean | optional | Defaults `true` | + +Not part of the serializer, but accepted in the same request and resolved by the view (see +[Create an account](#create-an-account)): `contacts`, `tags`, `teams`, `assigned_to` (each a list of +ids, org-scoped), and `account_attachment` (a multipart file). + +`GET /api/accounts/` and `GET /api/accounts/{id}/` additionally return, but never accept as input: +`id`, `created_by`, `created_at`, `org` (nested), `country_display`, `account_attachment`, `cases`, +`tasks`, `opportunities`, `rollups` (see [List accounts](#list-accounts)), and `custom_fields` +(validated separately against the org's `CustomFieldDefinition` rows). diff --git a/docs/api/authentication.md b/docs/api/authentication.md new file mode 100644 index 00000000..0eb3d6ec --- /dev/null +++ b/docs/api/authentication.md @@ -0,0 +1,256 @@ +# Authentication + +This page documents the calls a client makes to sign in and stay signed in: what to send, what +comes back, and what each failure mode means. For the design behind these endpoints — token +lifetimes, claims, why rotation is implemented by hand, why personal access tokens and organization +API keys exist alongside the JWT — see [Architecture: Authentication](../architecture/authentication.md), +which this page does not repeat or contradict. For a guided walkthrough of picking a sign-in path +locally, see [First sign-in](../getting-started/first-sign-in.md). + +All three interactive sign-in paths below — Google OAuth, Google ID token, and both magic-link +verify endpoints — mint tokens through the same `OrgAwareRefreshToken.for_user_and_org()` helper and +are public: `permission_classes = []`, `authentication_classes = []` +(`backend/common/views/auth_views.py`). None of them require an existing token to call. + +## Google OAuth (web) + +`POST /api/auth/google/callback/` (`GoogleOAuthCallbackView`, +`backend/common/views/auth_views.py:52-198`) completes a PKCE authorization-code exchange for the +SvelteKit frontend. + +Request body: + +```json +{ + "code": "", + "code_verifier": "", + "redirect_uri": "" +} +``` + +`redirect_uri` is taken from this field, not from any server-side setting — see +[Architecture: Authentication](../architecture/authentication.md#organization-api-keys) for the +related, dead `GOOGLE_REDIRECT_URI` config, and [Google OAuth](../self-hosting/google-oauth.md) for +the setting that actually matters. +The view exchanges these with Google, decodes the returned ID token, and rejects the sign-in +outright if Google's own `email_verified` claim is not `true` — an unverified Google address never +reaches the database. On success: + +```json +{ + "access_token": "", + "refresh_token": "", + "user": {"id": "", "email": "user@example.com"} +} +``` + +The token is minted with no org claim (`OrgAwareRefreshToken.for_user_and_org(user, None)`, +`:190`) — both Google flows always hand back an org-less token (the magic-link flows differ; see +[Magic links](#magic-links) below), so the client's next call is normally +[switching organization](#switching-organization). + +Failure responses: `400` for a missing `code`/`code_verifier`/`redirect_uri`, an invalid ID token, +a missing email, or an unverified email; `502` if Google's token endpoint could not be reached at +all (`requests.RequestException`, `:110-114`); `403` if the resulting user account is deactivated. + +## Google ID token (mobile) + +`POST /api/auth/google/` (`GoogleIdTokenView`, `backend/common/views/auth_views.py:201-319`) is the +mobile equivalent: the client has already obtained a Google ID token itself and hands it over +directly, rather than a code the server exchanges. + +Request body: + +```json +{"idToken": ""} +``` + +The server verifies it against Google (`google.oauth2.id_token.verify_oauth2_token`, checked +against `GOOGLE_CLIENT_ID`) and applies the same `email_verified` check as the web flow. On success: + +```json +{ + "JWTtoken": "", + "refresh_token": "", + "user": {"id": "", "email": "user@example.com", "name": "user", "profileImage": ""}, + "organizations": [{"id": "", "name": "Acme", "role": "ADMIN"}] +} +``` + +Note the access token field here is `JWTtoken`, not `access_token` — the two Google flows do not +share a response shape, so a client that supports both cannot read the access token the same way +from each. This response also includes `organizations` directly (the caller's existing memberships), +which the web callback response above does not; a mobile client can offer org selection without an +extra `GET /api/auth/me/` round trip. Failure responses mirror the web flow: `400` for a missing or +invalid `idToken`, a missing email, or an unverified email; `403` for a deactivated account. + +## Magic links + +Passwordless sign-in, in two deliveries, across three endpoints: + +`POST /api/auth/magic-link/request/` (`MagicLinkRequestView`, +`backend/common/views/auth_views.py:586-655`): + +```json +{"email": "user@example.com", "delivery": "link"} +``` + +`delivery` is optional and defaults to `"link"`; the only other value is `"code"`. This endpoint +**always returns `200`** with the same body, regardless of whether the address has an account, +whether it failed validation (a malformed or disposable-domain email), or whether it was rate +limited (five tokens per address per hour) — every one of those cases falls through to the same +generic response: + +```json +{"message": "If this email is valid, you will receive a sign-in link."} +``` + +This is deliberate — see [First sign-in](../getting-started/first-sign-in.md#magic-links) — and +means a client cannot distinguish "sent" from "rejected" from this response; there is no `400` to +handle here at all. A generated token expires after 10 minutes. + +`POST /api/auth/magic-link/verify/` (`MagicLinkVerifyView`, +`backend/common/views/auth_views.py:658-768`) exchanges the token from a link-delivery email: + +```json +{"token": "<64-character hex token from the emailed link>"} +``` + +`POST /api/auth/magic-link/verify-code/` (`MagicLinkVerifyCodeView`, +`backend/common/views/auth_views.py:771-899`) exchanges a code-delivery OTP instead: + +```json +{"email": "user@example.com", "code": "123456"} +``` + +A code is checked under `select_for_update` so two concurrent verify attempts can't both observe +the same attempt count, and is burned (marked used) after 5 failed attempts even if the correct +code is submitted later. Both verify endpoints return the same success shape: + +```json +{ + "access_token": "", + "refresh_token": "", + "user": { + "id": "", + "email": "user@example.com", + "profile_pic": "", + "is_active": true, + "organizations": [{"id": "", "name": "Acme", "role": "ADMIN", "is_organization_admin": true, "has_sales_access": true, "has_marketing_access": true}] + } +} +``` + +with one addition: if the user has **any** active org membership, the token is minted bound to +one of them and the response also includes: + +```json +{"current_org": {"id": "", "name": "Acme"}} +``` + +This is not gated on having exactly one membership — `Profile.objects.filter(user=user, +is_active=True).first()` (`backend/common/views/auth_views.py:740,745-746` for verify, `:874,878-879` +for verify-code) picks whichever active profile sorts first, and `Profile`'s default ordering is +`("-created_at",)` (`backend/common/models.py:238`), so for a user in more than one org this binds +to their **most recently created** membership, not necessarily the one they intend to use next. A +client for a user who might belong to multiple orgs cannot assume magic-link verification always +leaves org selection to a later step — it has to check whether `current_org` is present and let the +user switch away from it if it's the wrong one, via +[switching organization](#switching-organization). If there is no active org membership at all, the +token comes back org-less, same as the OAuth flows. +Failure responses: `400` with `{"error": "Invalid request"}` if the request body itself fails +validation (missing token, malformed email, code not exactly 6 digits); `400` with +`{"error": "Invalid or expired link"}` / `{"error": "Invalid or expired code"}` if the token or code +doesn't match an unused, unexpired row; `403` for a deactivated account. Verifying creates the user +if the email has never signed in before, exactly like the OAuth flows do. + +## Using an access token + +Send the access token as `Authorization: Bearer `. It is valid for one hour — see +[Token model](../architecture/authentication.md#token-model) for why, and what happens after it +expires. + +`GET /api/auth/me/` (`MeView`, `backend/common/views/auth_views.py:322-336`) returns the signed-in +user and every organization they have an active profile in: + +```json +{ + "id": "", + "email": "user@example.com", + "profile_pic": "", + "is_active": true, + "organizations": [ + {"id": "", "name": "Acme", "role": "ADMIN", "is_organization_admin": true, "has_sales_access": true, "has_marketing_access": true} + ] +} +``` + +Two things worth knowing about this specific endpoint: it sets `authentication_classes = +[JWTAuthentication]` explicitly (`:327`), so unlike most endpoints it does not also accept a +personal access token or an organization API key — only a JWT works here. And its exact path is one +of a short list `GetProfileAndOrg` skips org-context resolution for entirely +(`backend/common/middleware/get_company.py:29-38`) — the middleware returns immediately without +trying to set `request.profile`/`request.org`, so this endpoint never requires an `org_id` claim. +That is what makes it work with the org-less token an org-less sign-in above produces, and exactly +what makes it useful for letting a freshly-authenticated client list organizations to choose from. + +## Refreshing + +`POST /api/auth/refresh-token/` (`OrgAwareTokenRefreshView`, +`backend/common/views/auth_views.py:339-448`) exchanges a refresh token for a new access/refresh +pair: + +```json +{"refresh": ""} +``` + +```json +{"access": "", "refresh": ""} +``` + +Note the field names here — `access` and `refresh` — do not match `access_token`/`refresh_token` +used by the sign-in endpoints above; this is the one response on this page that uses the shorter +pair. Failure responses: `400` if `refresh` is missing; `401` if the token is expired, malformed, +or already blacklisted (`TokenError`); `403` if the user account has been deactivated since the +token was issued; `403` if the token carries an `org_id` the user no longer has an active profile +in. See [Refresh and rotation](../architecture/authentication.md#refresh-and-rotation) for what +happens to the old token on a successful call, and why. + +## Switching organization + +`POST /api/auth/switch-org/` (`OrgSwitchView`, `backend/common/views/auth_views.py:451-583`) moves +an authenticated caller to a different org and re-mints both tokens bound to it. Unlike most +endpoints it only requires `IsAuthenticated`, not org context, because it has to work for a token +that has none yet: + +```json +{ + "org_id": "", + "refresh": "" +} +``` + +```json +{ + "access_token": "", + "refresh_token": "", + "current_org": {"id": "", "name": "Acme"}, + "profile": {"id": "", "role": "ADMIN", "is_organization_admin": true} +} +``` + +`refresh` is optional; when it is sent and belongs to the caller, it is blacklisted as part of the +switch so the org-less (or previous-org) token stops working immediately rather than staying valid +for the rest of its 14-day life — see +[Organization switching](../architecture/authentication.md#organization-switching) for why the +ownership check on that field matters. Failure responses: `400` if `org_id` is missing or not a +valid UUID; `403` if the caller has no active profile in the requested org. + +## Local development + +For local development against a running backend with `DEBUG=True`, the fastest way to obtain a +token pair is the `devlogin` management command rather than any of the endpoints above — it needs +no OAuth provider and no outbound email. It refuses to run at all unless `DEBUG` is `True`. See +[First sign-in: Local development](../getting-started/first-sign-in.md) for its exact invocation, +flags, and org-resolution behavior; this page's endpoints are what `devlogin` is a shortcut around, +and the tokens it prints work identically against every endpoint described above. diff --git a/docs/api/cases.md b/docs/api/cases.md new file mode 100644 index 00000000..2830d779 --- /dev/null +++ b/docs/api/cases.md @@ -0,0 +1,439 @@ +# Cases + +Routes in `backend/cases/urls.py`. CRUD and comments/attachments live in `backend/cases/views.py`; +CSV import in `backend/cases/import_views.py` / `backend/cases/services/csv_import.py`; approvals in +`backend/cases/approval_views.py` and `backend/cases/approvals.py`; the knowledge base in +`backend/cases/solution_views.py`. Object-level authorization for the case itself is centralized in +`backend/cases/access.py`; for knowledge-base articles, `backend/cases/kb_access.py`. See +[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than +repeated here, and [Errors](errors.md) for the 404-vs-403 principle referenced below. + +Cases are also the largest module in this codebase — Kanban pipelines, merge/unmerge, parent/child +trees, time tracking, escalation, routing, inbound email and analytics all live under +`/api/cases/...` too. This page covers only the endpoints this documentation task scopes: list, +create, detail, comments/attachments, approvals, the knowledge base, and CSV import. + +## A case's read, write and delete rules differ on purpose + +Before anything else: **do not collapse a case's access rules into one check.** `cases/access.py` +states three explicit, different rules, and its own module docstring explains why — before this +module existed the three verbs had drifted into three different answers by accident, and a watcher's +ticket showed up in their list but answered `403` when they opened it: + +``` +read admin · creator · assignee · watcher +write admin · creator · assignee +delete admin · creator +``` + +(`cases/access.py:20-22`.) This is the same "read wide, write narrow" principle +[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#read-wide-write-narrow) +documents in general — that page is the place to read the reasoning; this page just applies it. +Every endpoint under [Retrieve, update, delete](#retrieve-update-delete) and +[Comments and attachments](#comments-and-attachments) calls one of +`assert_case_read_access`/`assert_case_write_access`/`assert_case_delete_access` +(`cases/access.py:90-113`), and the admin check itself is the two-spellings-of-admin form — +`profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False))` (`cases/access.py:38-40`) — +that recurs throughout this codebase. **The [Approvals](#approvals) endpoints are the exception**: +`cases/approval_views.py` never imports `cases.access` at all, and none of its case-touching views +call any of the three functions above — see [Approvals](#approvals) for exactly what that means in +practice. + +## List cases + +`GET /api/cases/` (`CaseListView.get`, `cases/views.py:285-287`) returns one paginated list, ordered +`-created_at, -id` (`:169-174`) — a comment on the queryset explains the tiebreak exists because `-id` +alone is a random UUID and was, in practice, no ordering at all. Soft-deleted cases +(`is_active=False`) are excluded by default; an org admin can pass `?include_deleted=true` to see them +(`:176-180`). Merged duplicates (`merged_into` set, or `status="Duplicate"`) are excluded by default +too; `?show_merged=true` includes them (`:181-186`). A non-admin caller sees only cases where they are +creator, assignee, or watcher — the same `visible_cases_qs` the detail view's read check uses +(`:190-198`), so list and detail agree by construction. + +```json +{ + "cases_count": 42, + "offset": 10, + "cases": [ { "...": "CaseSerializer" } ], + "status": [["New", "New"], ["Assigned", "Assigned"], ["Pending", "Pending"], ["Closed", "Closed"], ["Rejected", "Rejected"], ["Duplicate", "Duplicate"]], + "priority": [["Low", "Low"], ["Normal", "Normal"], ["High", "High"], ["Urgent", "Urgent"]], + "type_of_case": [["Question", "Question"], ["Incident", "Incident"], ["Problem", "Problem"]], + "open_count": 12, + "urgent_count": 2, + "awaiting_first_reply": 5, + "accounts_list": [ { "...": "AccountSerializer" } ], + "contacts_list": [ { "...": "ContactSerializer" } ], + "users": [{"id": "...", "user__email": "..."}] +} +``` + +(`cases/views.py:222-263`.) `open_count`, `urgent_count` and `awaiting_first_reply` are counted over +the whole filtered queryset, not the page. `awaiting_first_reply` counts open cases +(`status` in `New`/`Assigned`/`Pending`) with no `first_response_at` yet — it is not the same thing as +an SLA breach, which depends on the org's business calendar and is computed per-row rather than in +this aggregate. `accounts_list`/`contacts_list` feed the create form's pickers — org-wide for an admin, +but narrowed to accounts/contacts the caller created or is assigned to for a non-admin, the same split +the case queryset itself gets (`:187-206`); either way they can be heavy, and `?slim=true` omits both +(`:254-256`). + +Filters read directly from `request.query_params` (`apply_case_list_filters`, `cases/views.py:85-155`, +shared with `GET /api/cases/watching/`): `name` (contains), `status` (repeatable — a list applies +`status__in`, a single value applies exact match), `priority` (exact), `account` (exact id), +`case_type` (exact), `assigned_to` (repeatable id list), `tags` (id list), `search` (`name` or +`description`, contains), `created_at__gte`/`created_at__lte` (date range), `sla_breached=true` +(a wall-clock approximation of the SLA badge, Postgres-only raw SQL), `cf_` (custom field equals), +and `ordering` — whitelisted to `created_at`, `-created_at`, `priority`, `-priority`, `id`, `-id`, +`name`, `-name` (`:71-82,150-153`); anything else is silently ignored rather than erroring. + +## Create a case + +`POST /api/cases/` (`CaseListView.post`, `cases/views.py:306-416`) accepts `multipart/form-data` (for +an optional `case_attachment` file) or JSON, validated by `CaseCreateSerializer` +(`cases/serializer.py:139-281`; see [Fields](#fields)). On success (`200`): + +```json +{ + "error": false, + "message": "Case Created Successfully", + "id": "", + "cases_obj": { "...": "CaseSerializer" } +} +``` + +A validation failure returns `400` with `{"error": true, "errors": {...}}`. Two validators worth +knowing: + +- **`account`, if given, must belong to the caller's org** — `validate_account` rejects a + cross-tenant id with `"No such account."` (`cases/serializer.py:151-165`); the comment on it notes + this closes both a cross-tenant *write* and a cross-tenant *read*, since the create response nests + the account back through `CaseSerializer` → `AccountSerializer`. +- **`name` must be unique per org, case-insensitive** (`validate_name`, `:232-244`). + +`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `CaseCreateSerializer` — the view +reads each from the request body after the serializer succeeds, resolved against +`org=request.profile.org` (`:332-386`). Unlike [Leads](leads.md#fields), which documents a real +per-field, per-verb inconsistency in how these four are *parsed*, cases parses all four identically on +every verb: each is `json.loads`'d when it arrives as a string (which happens whenever the same +request also uploads `case_attachment` as multipart) before the id list is extracted. `case_attachment` +itself is read straight off `request.FILES` (`:388-395`) — on `POST` only; see +[Retrieve, update, delete](#retrieve-update-delete) for why `PATCH` can't set it at all, and for how +*writing* these four fields (not just parsing them) differs sharply across verbs. + +## Retrieve, update, delete + +`GET /api/cases/{id}/` (`CaseDetailView.get`, `cases/views.py:612-731`) looks the case up scoped to +the caller's org (`get_case_or_404`, `cases/access.py:57-71` — a malformed UUID or a case in another +org both answer `404`, never `500`, matching [Errors](errors.md#not-found-versus-forbidden)). If the +case has been merged into another one, the response is a redirect hint instead of the case itself — +`{"redirect_to": "", "merged_into": "", "source_case_id": "", "source_case_name": "..."}` +— unless the caller passes `?show_merged=true` (`:614-630`). **This merged-case branch returns before +any read-access check runs at all** — `assert_case_read_access` isn't called until `:633`, several +lines after the merged branch's own `return` at `:630` — so `source_case_name` is disclosed to any org +member who can supply a valid case id, including one with no read access to the case whatsoever, as +long as it happens to be a merged duplicate. On the ordinary, non-merged path, read access genuinely is +checked before the rest of the response body is built (`assert_case_read_access`, `:633` — the comment +on this line notes the previous order built the payload for a case the requester was about to be +refused); it's specifically the merged-redirect shortcut that skips the check entirely. The response nests +the record under `cases_obj`, alongside `attachments`, `comments` (public only), `internal_notes`, +`contacts`, `solutions`, `activities` (last 20), `email_messages` (last 50, inbound-email threads), +`merged_from_cases`, `custom_field_definitions`, `status`, `priority`, `type_of_case`, +`comment_permission` and `users_mention` (`:707-730`). `comment_permission` is computed with the same +`has_case_write_access` the comment-post endpoint enforces (`:642`), so the button a client shows and +the answer the server gives agree by construction — the comment on this line notes that used to not be +true (`comment_permission` was creator-or-admin while the write endpoint below also allowed assignees). + +`PUT /api/cases/{id}/` (`:448-570`) and `PATCH /api/cases/{id}/` (`:823-942`) both call +`assert_case_write_access` before touching anything. **`account` cannot be changed by either verb.** +`CaseCreateSerializer.__init__` sets `self.fields["account"].read_only = True` whenever `self.instance` +is set (`cases/serializer.py:146-149`) — true for both update calls, never for create — so on `PUT`/ +`PATCH` an `account` in the body is silently dropped before validation ever runs; `validate_account` +never executes and no error is returned. `PUT` is a non-partial serializer instantiation, so it still +requires `name`/`status`/`priority` the same as `POST` does; `PATCH` passes `partial=True` +(`:832`), so none of the three are required there — a `PATCH` may touch just one field. + +Closing a case is validated as a *transition*, not just a target value: `CaseCreateSerializer.validate()` +(`cases/serializer.py:167-230`) requires `closed_on` whenever `status` is being set to `"Closed"` from +anything other than `"Closed"`, and — when an active `pre_close` `ApprovalRule` matches the case's +priority/case_type/team — requires an `Approval` row in state `approved` for that case and rule before +the close is allowed. This exists because `Case.clean()` states the same two rules +(`cases/models.py:180-249`) but `Case.save()` (`:264-283`) never calls `full_clean()`, so without the +serializer-level check a matching rule could be armed and a `PATCH {"status": "Closed"}` would still +return `200` and record zero approvals — proven live per the comment on `validate()`. Success on either +verb, identical literal string on both (`views.py:564,936`): +`{"error": false, "message": "Case Updated Successfully"}`. + +**Three of `Case.clean()`'s parent-tree guards are dead code through the API — and one field that +looks like a partial re-implementation of the model isn't a re-implementation at all.** +`Case.clean()` (`cases/models.py:180-249`) states four rules about `parent`: a case cannot be its own +parent (`:222-223`); linking cannot create a multi-level cycle, walking the whole parent chain to check +(`:226-238`); the tree is capped at `PARENT_MAX_DEPTH = 3` levels (`:178,240-243`); and a case that is +itself `status="Duplicate"` cannot be given a parent at all (`:247-250`, the self-side of the merge +guard — distinct from `parent.status == "Duplicate"`, checked separately at `:245-246`). None of the +four run through any API write, because `Case.save()` (`:264-283`) never calls `full_clean()`. Of the +four, `CaseCreateSerializer.validate_parent()` (`cases/serializer.py:246-263`) independently +re-implements exactly two: the self-parent check, and refusing a parent whose own `status` is +`Duplicate`. **The cycle walk and the depth limit are simply absent from the API — not partially +covered, not approximated — and so is the "a Duplicate case cannot receive a parent" direction.** +`validate_parent()` also rejects a cross-org `parent`, but that check has no model-side counterpart at +all — `Case.clean()` never compares `org_id`, so this is a serializer-only addition, not a partial port +of anything the model already states. Net effect: a same-org, non-self parent assignment that would +create a longer cycle (A's parent is B, then B's parent is set to A), exceed three levels, or give a +merged case a parent, is accepted by every write endpoint today — reported here, not fixed, per this +documentation task's scope. + +`DELETE /api/cases/{id}/` (`:586-593`) calls `assert_case_delete_access` — admin or creator only, no +assignee exception (see [above](#a-cases-read-write-and-delete-rules-differ-on-purpose)). + +## Comments and attachments + +Adding a comment or an attachment is the same `POST /api/cases/{id}/` route the detail endpoint uses, +just with a body (`CaseDetailView.post`, `cases/views.py:749-806`), gated by +`assert_case_write_access` (`:755`) — the same rule `comment_permission` on `GET` reports. Send +`comment` (text, and optionally `is_internal` — a truthy string or boolean) and/or `case_attachment` +(multipart file), either or both. A comment created with `is_internal=true` is an internal note, not +visible to a customer-facing surface; the response splits both back out as `comments` (public) and +`internal_notes` (`:794-805`), and both are returned to any caller with read access — `is_internal` is +a display split, not an access-control split on this endpoint. + +Editing or deleting an *existing* comment goes through `PUT`/`PATCH`/`DELETE /api/cases/comment/{id}/` +(`CaseCommentView`, `cases/views.py:945-1078`) — restricted to an admin, the `is_organization_admin` +flag (exposed as `.is_admin`, `common/models.py:243-246` — org-scoped, not the platform-level +superuser flag), or the comment's own author (`request.profile == obj.commented_by`, e.g. `:982`). +`Comment.commented_by` is a `Profile` foreign key (`common/models.py:275-277`), so this comparison is +correct — unlike some `created_by` comparisons elsewhere in this codebase; see +[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks). + +**`PUT` cannot, in practice, edit a comment.** `.put()` builds `CommentSerializer(obj, data=params)` +with no `partial=True` (`:984`), and `CommentSerializer.Meta.fields` includes `object_id` and `org` +(`common/serializer.py:239-250`) — plain model fields, not declared `read_only` the way `content_type` +and `commented_by` are on the same serializer. A non-partial serializer requires every field that isn't +read-only, so a body of just `{"comment": "..."}` — everything the documented +`CaseCommentEditSwaggerSerializer` contract asks for — fails validation on the missing `object_id`/ +`org` and answers `400`. Only `.patch()` (`partial=True`, `:1028`) can actually change a comment's +text. Separately, because `object_id` and `org` are genuinely writable on `PATCH`, a comment's own +author can send both in a `PATCH` body to repoint the comment at a different record — including one in +another org, or one the author cannot otherwise read. `Comment.clean()` +(`common/models.py:299-314`) checks that the new `org` matches the target record's `org`, and +`Comment.save()` calls `self.full_clean()` (`:316-318`), so a mismatched pair raises Django's +`ValidationError`, uncaught here, as an unhandled `500`; a *matching* cross-org pair (new `org` set to +the target record's real org) saves successfully, leaving a comment authored by a profile from a +different org sitting on that org's record. Neither of these two behaviors is fixed here — reported +per this documentation task's scope. + +`DELETE /api/cases/attachment/{id}/` (`CaseAttachmentView.delete`, `cases/views.py:1098-1142`) is +org-scoped (`.filter(pk=pk, org=request.profile.org)`, `:1119-1121`) and its ownership check compares +`request.profile.user_id` to `self.object.created_by_id` — both `User` ids, correctly typed +(`:1128-1130`). The view's own docstring documents two defects that were live in this exact endpoint +before the fix (unscoped lookup; a `Profile`-to-`User` comparison that made the endpoint silently +admin-only) — both are fixed here today. Whether the equivalent endpoint in another app still has +either defect is that app's own question; verify against that app's current source rather than this +docstring, which itself goes stale — see +[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) +for the current, cross-app answer. + +## Approvals + +Two kinds of endpoint: admin-configured rules, and per-case requests/decisions against them +(`cases/approval_views.py`). A rule (`ApprovalRule`, `cases/approvals.py:47-116`) matches a case on +`trigger_event` (only `"pre_close"` exists today), and optionally `priority`, `case_type` and `team` — +the most-specific active match wins ties by most recent (`find_matching_rule`, `:119-136`). + +- `GET /api/cases/approval-rules/` (`ApprovalRuleListCreateView.get`, `approval_views.py:87-103`) — + any org member; each rule row carries a `pending_count` computed from the `Approval` log. +- `POST /api/cases/approval-rules/` (`:105-133`) — admin only. The approver list and match-team are + each validated to belong to the caller's org before save (`:117-129`). +- `GET/PUT/DELETE /api/cases/approval-rules/{id}/` (`ApprovalRuleDetailView`, `:136-194`) — `GET` is + open to any member; `PUT`/`DELETE` are admin only. Deleting a rule with request history soft-disables + it (`is_active=False`) instead of a hard delete (`:183-192`), since `Approval.rule` is + `on_delete=PROTECT`. +- `POST /api/cases/{id}/request-approval/` (`CaseRequestApprovalView.post`, `:201-279`) — **no case + access check of any kind.** The view fetches the case with a bare + `get_object_or_404(Case, id=pk, org=org)` (`:209`) — org-scoped, but nothing from `cases.access`; + `approval_views.py` doesn't import that module at all. Any org member, including one refused the case + itself with a `403` on `GET /api/cases/{id}/`, can file an approval request against it. The `201` + response is `ApprovalSerializer(approval).data`, whose `case_summary` + (`cases/serializer.py:1002-1013`) returns the case's `name`, `status`, `priority` and account name — + the same read-around `CaseSolutionLinkView` was fixed for (see [Solutions](#solutions)), reintroduced + here. Also note: because the lookup is `get_object_or_404` rather than the `get_case_or_404` helper + every other case endpoint on this page uses, a malformed (non-UUID) `id` here isn't a clean `404` — + `get_object_or_404` only catches `Model.DoesNotExist`, not the `ValidationError`/`ValueError` a bad + UUID raises against `Case.id`, so it reaches the client as an unhandled `500`, the exact failure mode + `get_case_or_404` (`cases/access.py:57-71`) exists to prevent elsewhere on this page. Beyond + existence-and-org-scope, the request is validated against an explicit `rule_id` (which must actively + match the case) or, when omitted, the best `find_matching_rule` result. A second request against the + same case+rule while one is already `pending` is refused with `409`, not a duplicate row (`:244-258`). +- `GET /api/cases/approvals/` (`ApprovalInboxView.get`, `:282-341`) — the inbox, also with no + case-level access check: the queryset is `Approval.objects.filter(org=org)` (`:296`), not filtered + through `visible_cases_qs` or any case-access helper, so a member's `mine=false` inbox view can + include approval rows — and their `case_summary` — for cases that member has no access to. + `?state=` filters (default `pending`; `all` drops the filter), `?case=` scopes to one case, and + `?mine=true` restricts to rows the caller can currently act on (see below) — which deliberately + excludes the caller's own requests, "mine to decide" rather than "mine to have filed" (`:318-332`). + Neither gap is fixed here — reported per this documentation task's scope. + +**Approving and rejecting both enforce that the requester cannot be the approver, with no admin +exception.** `ApprovalApproveView.post` (`:354-407`) and `ApprovalRejectView.post` (`:410-475`) both +check, in order: the approval is still `pending` (else `400`); the caller is in the rule's approver +pool via `can_be_acted_on_by` (else `403`); and — separately — +`approval.requested_by_id == request.profile.id` (else `403`, "You cannot approve/reject your own +request; another approver must decide it.", `:382-390` and `:443-451`). The comment on both is explicit +that this holds even for "an admin who defaults into every rule's pool" — being an approver and being +the requester are mutually exclusive for a single decision, regardless of role. +`Approval.can_be_acted_on_by` (`cases/approvals.py:188-196`) itself is: explicitly listed in +`rule.approvers`, **or** `profile.role == rule.approver_role` — note this second clause checks only +`role`, not the `is_organization_admin` flag `is_org_admin` elsewhere in this codebase also treats as +admin, so a profile that is only an admin via that flag is not automatically an approver unless +explicitly added to `rule.approvers`. + +`ApprovalSerializer.can_act` and `.is_own_request` (`cases/serializer.py:1045-1062`) mirror the approve +endpoint's checks exactly, so a client rendering the inbox from the list response gets the same answer +the action endpoints would give — both need `context={"request": request}` to resolve; without it they +default to `False`, which the comment on the serializer notes is the safe default rather than a bug. + +`POST /api/cases/approvals/{id}/cancel/` (`ApprovalCancelView.post`, `:478-518`) is a **different** +rule from approve/reject: only the original requester or an admin may cancel (`:498-505`) — there is no +"not the requester" restriction here, because cancelling your own request is the ordinary case. + +## Solutions + +The knowledge base (`cases/solution_views.py`, `cases/kb_access.py`). Read access is org-wide by +design — every member may read every article, "a knowledge base whose articles are hidden from the +agents answering the tickets is not a knowledge base" (`cases/kb_access.py` module docstring) — but +write and release are two different, narrower rules: + +``` +write author · admin title, body, and moving draft ↔ reviewed +release admin status → approved, and publish / unpublish +delete author · admin +``` + +`release` is deliberately narrower than `write`: the review workflow (`draft → reviewed → approved`, +plus a separate `is_published` flag) only means something if the person who approves an article is not +its own author. `assert_solution_release_access` takes no article argument at all — being the author is +the one thing that must not grant it (`cases/kb_access.py:72-81`). + +`GET /api/cases/solutions/` (`SolutionListView.get`, `cases/solution_views.py:88-142`) is the one +endpoint in this API that calls DRF's `get_paginated_response()` and returns its standard +`count`/`next`/`previous`/`results` envelope — [Conventions](conventions.md#pagination) names this +exact endpoint as the sole example. It adds one extra top-level key, `totals` +(`count`/`published`/`draft`/`reviewed`/`approved_unpublished`), computed over the *whole* knowledge +base rather than the current filter, so the four status cards stay meaningful under a `?status=` +filter (`:122-134`). Filters: `?status=`, `?is_published=` (accepts `true`/`1`/`yes`/`on`, not just the +literal string `"true"`), `?search=` (title or description, contains). + +`POST /api/cases/solutions/` (`:150-167`) is open to any member for an ordinary draft, but is gated by +`assert_solution_release_access` the moment the payload would create an already-`approved` or +already-`is_published` article in one request (`_wants_release`, `:28-46`) — without this, an author +could write and publish an article in a single `POST`, bypassing the review workflow entirely. + +`GET/PUT/PATCH/DELETE /api/cases/solutions/{id}/` (`SolutionDetailView`, `:170-237`): `GET` is open to +any member; `PUT`/`PATCH` require `assert_solution_write_access` (author or admin) plus, again, +`assert_solution_release_access` if the request would move the article to `approved` or flip +`is_published`; `DELETE` requires the same write rule (not a separate, narrower one — deleting your own +draft is ordinary tidying). + +`POST /api/cases/solutions/{id}/publish/` and `POST /api/cases/solutions/{id}/unpublish/` +(`SolutionPublishView`/`SolutionUnpublishView`, `:240-295`) both require +`assert_solution_release_access`. Publishing additionally requires `status == "approved"` (`400` +otherwise); the comment on the unpublish view is explicit that pulling a bad answer down is the *same* +switch as giving it to customers and therefore gated the same way, even though the urgency argument +runs the other direction. + +Reading which solutions are linked to a case is not a separate endpoint — the linked set is returned +as part of `GET /api/cases/{id}/` (`solutions` key, [above](#retrieve-update-delete)). Linking is +`POST /api/cases/{id}/solutions/` and unlinking is `DELETE /api/cases/{id}/solutions/{solution_id}/` +(`CaseSolutionLinkView`, `cases/views.py:1145-1208`), gated by `assert_case_write_access` on the +*case* — the comment on `.post` notes this closes a read-around: before this check, a member refused a +case with `403` could still link an article to it and then read the case's name, description, account +and contacts back out through that article's own `linked_cases`. The read side of the same gap is +closed in `cases/solution_serializers.py`'s `SolutionDetailSerializer.get_linked_cases`, which filters +the cases it returns through the same `visible_cases_qs` the case list and detail views use, rather +than returning every case the article happens to be linked to. + +## CSV import + +Two endpoints, gated to org admins or members with `has_sales_access` (`_can_import`, +`cases/import_views.py:23-31`) — anyone else gets `403`. + +`POST /api/cases/import/preview/` (`CaseImportPreviewView`, `import_views.py:56-88`) reads a +`multipart/form-data` upload in a field named `file` (`.csv` extension required). The 5 MB cap is +checked against `upload.size` — Django's reported size for the uploaded file, available before +`.read()` is called — **not** the bytes actually read, and the check is skipped entirely when +`upload.size` is falsy (`if upload.size and upload.size > MAX_UPLOAD_BYTES`, `import_views.py:48-51`). +It then validates every row without writing anything. Row numbers are 1-based over *data* rows, not +file lines — the header itself is never counted, so the first row of data is `"row": 1` +(`enumerate(data_rows, start=1)` over `rows[1:]`, `services/csv_import.py:216-217`): + +```json +{ + "header_error": null, + "valid": [ + { + "row": 1, "name": "Cannot log in", "status": "New", "priority": "High", + "description": "", "case_type": "Incident", "closed_on": null, + "account_id": "", "contact_ids": [], "assigned_ids": [], + "team_ids": [], "tag_names": [] + } + ], + "errors": [{"row": 2, "field": "status", "message": "Status must be one of: New, Assigned, Pending, Closed, Rejected, Duplicate"}], + "summary": {"total": 2, "valid": 1, "invalid": 1} +} +``` + +`POST /api/cases/import/commit/` (`CaseImportCommitView`, `:91-125`) re-validates the same file and +writes every valid row inside one transaction — **if any row fails validation, nothing is written** +(`services/csv_import.py` `commit_rows`, "Don't write anything if any row failed; users must fix the +file first"). On success (`200`): `{"error": false, "created": 12, "ids": ["", "..."]}`. + +Required headers: `name`, `status`, `priority`. Recognized optional headers: `description`, +`case_type`, `account_name`, `contact_emails`, `assigned_emails`, `team_names`, `tags`, `closed_on` +(`REQUIRED_HEADERS`/`OPTIONAL_HEADERS`, `services/csv_import.py:36-46`) — an unknown header, or a +missing required one, fails the whole file with `header_error` before any row is checked. +`contact_emails`, `assigned_emails` and `team_names` accept `;`-separated multiple values per cell and +must resolve to an existing contact/active member/team in the caller's org, or the row errors +(`:396-438`). `status`/`priority`/`case_type` are matched case-insensitively against the same choice +sets [Fields](#fields) documents for the API proper. A case name must be unique per org — checked +against both existing cases and other rows earlier in the same file (`:338-355`). The file is capped +at 5,000 data rows (`MAX_ROWS`, `:49`). + +## Fields + +`CaseCreateSerializer.Meta.fields` (`cases/serializer.py:265-281`) is what `POST /api/cases/`, +`PUT /api/cases/{id}/` and `PATCH /api/cases/{id}/` accept. "Required" below means required on +`POST` and on `PUT` (which is non-partial); `PATCH` passes `partial=True`, so nothing is required +there — see [Retrieve, update, delete](#retrieve-update-delete). + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `name` | string, max 64 | **required** (POST/PUT) | Unique per org, case-insensitive | +| `status` | one of `STATUS_CHOICE` | **required** (POST/PUT) | `New`, `Assigned`, `Pending`, `Closed`, `Rejected`, `Duplicate` | +| `priority` | one of `PRIORITY_CHOICE` | **required** (POST/PUT) | `Low`, `Normal`, `High`, `Urgent` | +| `case_type` | one of `CASE_TYPE` | optional | `Question`, `Incident`, `Problem` | +| `closed_on` | date | **required when closing** | See [Retrieve, update, delete](#retrieve-update-delete); optional otherwise | +| `description` | text | optional | | +| `is_active` | boolean | optional | Defaults `true`; soft-delete flag, hidden from `GET /api/cases/` by default | +| `account` | uuid | optional on create; **write-once** | Must belong to the caller's org; silently `read_only` on `PUT`/`PATCH` — see [Retrieve, update, delete](#retrieve-update-delete) | +| `custom_fields` | object | optional | Validated against the org's `CustomFieldDefinition` rows | +| `parent` | uuid | optional | Same-org, non-self, parent not `Duplicate`; see the dead model-level guards [above](#retrieve-update-delete) | +| `is_problem` | boolean | optional | Defaults `false`; marks an ITIL "problem" (umbrella) ticket | +| `org` | — | **read-only** | Server-derived from `request.profile.org`; listed in `Meta.fields` but not writable | + +Not part of the serializer, but accepted in the same request body and resolved by the view (each a +list of ids, org-scoped): `contacts`, `teams`, `assigned_to`, `tags`. Parsing is identical on every +verb (see [Create a case](#create-a-case)), but *writing* is not, and the difference is a real +data-loss trap: `POST` only ever adds. `PUT` calls `.clear()` on all four M2Ms **unconditionally**, +before checking whether the request even mentioned them (`views.py:485,501,515,531`) — so a `PUT` +that omits `assigned_to` from the body unassigns every assignee on the case, not just leaves them +unchanged. `PATCH` clears a given M2M only when its key is present in the body at all +(`if "assigned_to" in params:`, `:903`, and identically for the other three) — omitting a key on +`PATCH` genuinely leaves it untouched. `case_attachment` (a multipart file) is accepted on `POST` and +`PUT` (`views.py:388,545`) but **not on `PATCH`** — `CaseDetailView.patch` never reads +`request.FILES` at all, so a multipart `PATCH` carrying `case_attachment` silently drops the file with +no error. + +`GET /api/cases/{id}/` and `GET /api/cases/` additionally return, but never accept as input: `id`, +`created_by`, `created_at`, `escalation_count`, `last_escalation_fired_at`, the SLA fields +(`sla_first_response_hours`, `sla_resolution_hours`, `first_response_at`, `resolved_at`, +`sla_paused_at`, `first_response_sla_deadline`, `resolution_sla_deadline`, +`is_sla_first_response_breached`, `is_sla_resolution_breached`), `parent_summary`, `child_count`, and +`time_summary` (`CaseSerializer.Meta.fields`, `cases/serializer.py:97-136`). diff --git a/docs/api/contacts.md b/docs/api/contacts.md new file mode 100644 index 00000000..566bf61b --- /dev/null +++ b/docs/api/contacts.md @@ -0,0 +1,207 @@ +# Contacts + +Routes in `backend/contacts/urls.py`: `backend/contacts/views.py` for CRUD and comments/attachments, +`backend/contacts/import_views.py` and `backend/contacts/services/csv_import.py` for CSV import. See +[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than +repeated here. + +## Two links to an account, not one + +Before anything else: **a contact can be joined to an account two different ways**, and they are +independent. `Contact.account` is a foreign key the model itself calls "Primary account this contact +belongs to" (`contacts/models.py:71-78`); `Account.contacts` is a separate many-to-many +(`accounts/models.py:63-65`). Nothing in the database keeps them in sync — setting one does not set +the other by itself. The view layer bridges them on write: `link_primary_account(contact)` +(`contacts/views.py:48-65`) adds the contact to `contact.account.contacts` whenever `account` is set +via `POST /api/contacts/`, `PUT /api/contacts/{id}/` or `PATCH /api/contacts/{id}/` — but clearing the +FK later does **not** remove the M2M membership, on purpose (a person can be "no longer the primary +contact" without having "left the company" — `contacts/views.py:59-62`). + +Both links are exposed on every `ContactSerializer` response, deliberately, because reading only one +tells the wrong story depending on the data: + +```json +{ + "account": "", + "account_detail": {"id": "", "name": "Acme Inc"}, + "linked_accounts": [{"id": "", "name": "Acme Inc"}] +} +``` + +`account_detail` resolves the FK (`contacts/serializer.py:28-39`); `linked_accounts` is the M2M +membership (`:41-56`). The account detail page's contact list is built from `Account.contacts` (the +M2M), not the FK — so a contact whose `account` FK is set but was never added to that account's +`contacts` (see [CSV import](#csv-import) below for exactly this case) will not appear there even +though `account_detail` on the contact itself looks correct. + +## List contacts + +`GET /api/contacts/` (`ContactsListView.get`, `contacts/views.py:199-201`) returns a single +paginated list — unlike leads and accounts, it is not split into two sections. It still doesn't call +DRF's `get_paginated_response()` though — [Conventions](conventions.md#pagination) names +`GET /api/cases/solutions/` as the only endpoint in this API that does. The view hand-builds `count` +and `results` itself (`contacts/views.py:166-167`), with no `next`/`previous`, alongside a legacy set +of keys kept for backwards compatibility (`:168-171`): + +```json +{ + "count": 41, + "results": [ { "...": "ContactSerializer" } ], + "per_page": 10, + "page_number": [1], + "contacts_count": 41, + "offset": 10, + "contact_obj_list": [ { "...": "same rows as results" } ], + "countries": [["GB", "United Kingdom"]], + "users": [{"id": "...", "user__email": "..."}] +} +``` + +`active_count` and `inactive_count` (`contacts/views.py:147-148`) are computed over the full filtered +queryset *before* any `is_active` filter is applied, so a client can show "12 active, 3 inactive" while +also asking for only one of the two via `?is_active=true`/`?is_active=false` +(`:149-150`) — the only exact-match boolean filter on this endpoint; every other filter here is a +substring match: `name` (`first_name` or `last_name`), `city`, `phone`, `email`, `search` +(`first_name`, `last_name`, `email` or `phone`), plus `assigned_to`/`tags` (repeatable id lists), +`created_at__gte/lte`, and `cf_` (`contacts/views.py:90-139`). A non-admin caller only sees +contacts assigned to them or created by them (`:83-87`). + +## Create a contact + +`POST /api/contacts/` (`ContactsListView.post`, `contacts/views.py:218-311`) is validated by +`CreateContactSerializer` (`contacts/serializer.py:102-174`); see [Fields](#fields). On success +(`200`): + +```json +{"error": false, "message": "Contact created Successfuly", "id": ""} +``` + +(The message string is spelled exactly that way in the response — `Successfuly`, not `Successfully`; +document your integration tests against the real string, not the expected one.) Two validators worth +knowing: **`email` must be unique per org, case-insensitive**, checked only when supplied +(`validate_email`, `contacts/serializer.py:129-145`); and **`account`, if given, must belong to the +caller's org** — `validate_account` rejects a UUID from any other org's `Account` table with +`"No such account."` rather than silently attaching a cross-tenant record +(`contacts/serializer.py:114-127`). `teams`, `assigned_to` and `tags` are not part of the serializer +either — the view resolves each from the request body, tolerating either a JSON array or a +JSON-encoded string of ids (or of `{"id": "..."}` objects), consistently across all three fields and +across `POST` (`contacts/views.py:247-284`), `PUT` (`:478-518`) and `PATCH` (`:832-876`) alike — +contacts does not have the per-field, per-verb inconsistency [Leads](leads.md#fields) documents for +the equivalent fields there. `contact_attachment` is accepted as a multipart file. + +## Retrieve, update, delete + +`GET /api/contacts/{id}/` (`ContactDetailView.get`, `contacts/views.py:565-654`) org-scopes the +lookup and maps a malformed id to `404` the same way accounts does +(`get_object`, `:318-332`). `assert_contact_access` (`:334-361`) is the broadest access rule in this +group of four pages: an admin, the contact's creator, one of its assignees, **or anyone assigned to +an account the contact is linked to by either route** (FK or M2M — `account_ids`, `:363-369`) may +read, update or comment on the contact; anyone else gets `403`. The response nests the record under +`contact_obj` and also returns `address_obj` (the flat address fields, echoed as their own object), +`countries` (`contacts/views.py:608`), `comments`, `attachments`, `assigned_data`, `tasks`, +`users_mention`, `custom_field_definitions`, and three computed, capped lists: `opportunities` +(up to 10, via `Opportunity.contacts`), +`cases` (up to 10, via `Case.contacts`), and `colleagues` (up to 8 — other contacts sharing either +account link, matched on the account relationship rather than the free-text `organization` field, +which frequently names a different company — `contacts/views.py:371-424`). + +`PUT /api/contacts/{id}/` (`:441-544`) and `PATCH /api/contacts/{id}/` (`:793-881`) both call +`link_primary_account` after saving (`:477`, `:830`), so changing `account` through either verb keeps +`Account.contacts` in step the same way create does. Success: +`{"error": false, "message": "Contact Updated Successfully"}`. + +`DELETE /api/contacts/{id}/` (`:670-691`) is narrower than read/write access on purpose — no assignee +exception, only an admin or the contact's own creator may delete +(`self.request.profile.user_id != self.object.created_by_id`, `:676-679`). + +## CSV import + +Two endpoints, both gated to org admins or members with `has_sales_access` +(`_can_import`, `contacts/import_views.py:23-31`) — an ordinary member gets `403`: + +`POST /api/contacts/import/preview/` (`ContactImportPreviewView`, `import_views.py:64-96`) reads a +`multipart/form-data` upload in a field named `file` (must end in `.csv`, capped at 5 MB, checked +against the actual bytes read rather than the client-supplied `Content-Length`, +`import_views.py:34-61`), validates every row without writing anything, and returns: + +```json +{ + "header_error": null, + "valid": [ + { + "row": 2, "first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com", + "phone": "2025551234", "organization": null, "title": null, "department": null, + "do_not_call": false, "linkedin_url": null, "address_line": null, "city": null, + "state": null, "postcode": null, "country": null, "description": null, + "account_id": "", "assigned_ids": [], "team_ids": [], "tag_names": [] + } + ], + "errors": [{"row": 3, "field": "email", "message": "'not-an-email' is not a valid email"}], + "summary": {"total": 2, "valid": 1, "invalid": 1} +} +``` + +`POST /api/contacts/import/commit/` (`ContactImportCommitView`, `:99-133`) re-validates the same file +and writes inside one transaction (`commit_rows` / `_commit_validated`, +`contacts/services/csv_import.py:634-714`) — if any row fails validation, **nothing is written** +(`:650-657`); a concurrent conflict (e.g. a duplicate email created by another request between preview +and commit) rolls the whole batch back and reports `400` rather than partially importing +(`:659-672`). On success (`200`): + +```json +{"error": false, "created": 12, "ids": ["", "..."]} +``` + +The CSV must have a header row with `first_name` and `last_name` at minimum; the full list of +recognized optional headers is `email`, `phone`, `organization`, `title`, `department`, +`do_not_call`, `linkedin_url`, `address_line`, `city`, `state`, `postcode`, `country`, +`description`, `account_name`, `assigned_emails`, `team_names`, `tags` +(`REQUIRED_HEADERS`/`OPTIONAL_HEADERS`, `csv_import.py:46-66`); an unrecognized header or a missing +required one fails the whole file with `header_error` before any row is checked. `assigned_emails` +and `team_names` accept `;`-separated multiple values per cell. Duplicates are rejected per org: email +and phone (normalized) are always hard errors; a matching first+last name is only a hard error when +the row supplies **neither** an email nor a phone to disambiguate it from an existing contact +(`csv_import.py:8-19`, `408-514`). The file is capped at 5,000 data rows (`MAX_ROWS`, `:68`). + +**A row with `account_name` sets only the `account` foreign key, not the `Account.contacts` +membership.** `_commit_validated` creates each `Contact` with `account_id=vr.account_id` directly +(`csv_import.py:681-700`) and never calls `link_primary_account` — unlike `POST /api/contacts/` and +the `PUT`/`PATCH` update endpoints, which always do (see [Two links to an account](#two-links-to-an-account-not-one)). +A contact imported this way resolves correctly under `account_detail`, but will not show up on that +account's contact list (`Account.contacts`, the M2M) until something else adds it there. This looks +like an oversight rather than a documented design choice — it is reported here, not fixed, per this +documentation task's scope. + +## Fields + +`CreateContactSerializer.Meta.fields` (`contacts/serializer.py:147-174`) is what +`POST /api/contacts/`, `PUT /api/contacts/{id}/` and `PATCH /api/contacts/{id}/` accept. Unlike +`Lead.first_name`/`last_name`, `Contact.first_name` and `Contact.last_name` have neither +`blank=True` nor a serializer override (`contacts/models.py:19-20`) — **both are genuinely +required**. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `first_name` | string | **required** | | +| `last_name` | string | **required** | | +| `email` | string | optional | Unique per org, case-insensitive, when supplied | +| `phone` | string | optional | | +| `organization` | string | optional | Free text — may name a different company than `account` links to | +| `title` | string | optional | | +| `department` | string | optional | | +| `do_not_call` | boolean | optional | Defaults `false` | +| `linkedin_url` | string | optional | Validated as a URL | +| `address_line`, `city`, `state`, `postcode` | string | optional | | +| `country` | one of `COUNTRIES` | optional | | +| `description` | text | optional | | +| `account` | uuid | optional | Must belong to the caller's org; see [Two links to an account](#two-links-to-an-account-not-one) | +| `is_active` | boolean | optional | Defaults `true` | + +Not part of the serializer, but accepted in the same request and resolved by the view (see +[Create a contact](#create-a-contact)): `teams`, `assigned_to`, `tags` (each a list of ids, +org-scoped), and `contact_attachment` (a multipart file). + +`GET /api/contacts/` and `GET /api/contacts/{id}/` additionally return, but never accept as input: +`id`, `created_by`, `created_at`, `updated_at`, `org` (nested), `account_detail`, `linked_accounts` +(both described above), `contact_attachment`, and `custom_fields` (validated separately against the +org's `CustomFieldDefinition` rows). diff --git a/docs/api/conventions.md b/docs/api/conventions.md new file mode 100644 index 00000000..93d95d3e --- /dev/null +++ b/docs/api/conventions.md @@ -0,0 +1,202 @@ +# Conventions + +This page describes patterns that hold across most of the API — how the base URL is put together, +how a client authenticates, how list endpoints paginate and filter, and what a response body looks +like. It is deliberately drawn from the code that is actually there (a real pagination class, a +real list view, the real `REST_FRAMEWORK` settings) rather than from what a typical DRF API "should" +look like — several of these conventions are less uniform than that would suggest, and the +differences matter to anyone integrating against them. + +## Base URL + +Every endpoint in this section, and every endpoint in [Endpoint index](endpoint-index.md), is +mounted under `/api/` at the Django root (`backend/crm/urls.py`: `path("api/", +include("common.app_urls", ...))`), which in turn includes `backend/common/urls.py` — the file most +of this section's page references point at — at the same, empty prefix. Individual apps are mounted +under their own segment from there, for example `path("leads/", include("leads.urls", ...))`, so a +lead endpoint is `/api/leads/…`. + +Locally this is `http://localhost:8000/api/` (the default in the Docker quick start's +`.env.docker`, and what `PUBLIC_DJANGO_API_URL` points the frontend at — see +[Docker quick start](../getting-started/docker-quick-start.md)). In a self-hosted deployment it is +whatever host you put `DOMAIN_NAME` / `ALLOWED_HOSTS` on, still under `/api/`. + +## Authentication header + +Three credential types authenticate a request, tried by `common.middleware.get_company.GetProfileAndOrg` +in this order and, independently, by the DRF authentication classes in `DEFAULT_AUTHENTICATION_CLASSES` +(`backend/crm/settings.py`, `common.pat_auth.PATAuthentication`, +`rest_framework_simplejwt.authentication.JWTAuthentication`, `common.external_auth.APIKeyAuthentication`): + +``` +Authorization: Bearer # interactive sign-in (see authentication.md) +Authorization: Bearer bcrm_pat_... # personal access token +Token: bcrm_pat_... # personal access token, alternate header +Token: # organization API key +``` + +A personal access token is recognized by its `bcrm_pat_` prefix and can be presented either as a +bearer token or in the `Token` header (`backend/common/pat_auth.py`, `_extract_raw`); an organization +API key is only ever read from `Token` (`backend/common/external_auth.py`, +`APIKeyAuthentication.authenticate`). See [Authentication](authentication.md) for how a JWT is +obtained and [Tokens and API keys](tokens-and-api-keys.md) for the other two. Which identity a +request authenticates as, and which org it is scoped to, is never something the request supplies +directly — it is derived from whichever of these three the server can validate. + +One thing the generated OpenAPI schema does *not* reflect accurately: a large minority of +operations — 112 of the schema's 350, about a third — declare an `org` header parameter +(`organization_params_in_header`, defined in `backend/common/swagger_params.py` and copied into the +`swagger_params.py` of six other apps: `leads`, `cases`, `opportunity`, `contacts`, `accounts` and +`tasks`). Nothing in the +request-handling code reads a header by that name — org context comes only from the JWT's `org_id` +claim, the PAT's owning profile, or the API key's associated org, as above. Sending an `org` header +has no effect; do not rely on it. + +## Pagination + +List endpoints paginate with `rest_framework.pagination.LimitOffsetPagination` +(`DEFAULT_PAGINATION_CLASS` in `backend/crm/settings.py`), using the query parameters `limit` and +`offset`. The default page size is `PAGE_SIZE = 10` from the same settings block; there is no +`max_limit` set on the pagination class, so a caller can request a larger page and get it. + +That default pagination class is not applied automatically, though — there is no +`generics.ListAPIView` anywhere in this codebase. Every list endpoint is a hand-written `APIView` +that mixes `LimitOffsetPagination` in directly and calls `self.paginate_queryset(...)` itself, for +example: + +```python +class LeadListView(APIView, LimitOffsetPagination): + ... + def get_context_data(self, **kwargs): + ... + results_leads_open = self.paginate_queryset( + queryset_open.distinct(), self.request, view=self + ) +``` + +(`backend/leads/views/lead_views.py:51`, `:167`.) The same shape repeats in `common/views/user_views.py`, +`common/views/team_views.py`, `common/views/document_views.py`, `common/views/tags_views.py`, +`cases/views.py`, `contacts/views.py`, `accounts/views.py`, `opportunity/views/opportunity_views.py`, +`tasks/views/task_views.py` and `invoices/api_views.py`, among others. + +Because pagination is applied by hand, the response envelope is a per-endpoint choice, not a +guarantee. The common case in this codebase is that a view builds its own shape rather than calling +DRF's `get_paginated_response()`: `GET /api/leads/` reports pagination per section, since the list +is split into open and closed leads: + +```python +context["open_leads"] = { + "leads_count": self.count, # LimitOffsetPagination's own running total + "open_leads": open_leads, # this page's serialized results + "offset": offset, # offset to request for the next page, or None on the last page +} +``` + +(`backend/leads/views/lead_views.py:180-184`.) But `get_paginated_response()` — and with it, DRF's +standard `count`/`next`/`previous`/`results` shape — does appear: `GET /api/cases/solutions/` +(`SolutionListView`, `backend/cases/solution_views.py:56`, routed at `backend/cases/urls.py:59`) +calls it directly and adds one extra key on top: + +```python +response = self.get_paginated_response(serializer.data) +response.data["totals"] = counts +return response +``` + +(`backend/cases/solution_views.py:140-142`.) So both shapes are real. Treat the exact pagination +field names, and whether the standard DRF envelope or a hand-rolled one is used, as per-endpoint — +`limit`/`offset` as query parameters are consistent, but where the resulting count, next-offset and +results end up in the response body is not; check the endpoint you're calling. + +## Filtering and search + +There is no shared filter backend — `DEFAULT_FILTER_BACKENDS` is not set in `REST_FRAMEWORK`, and +no view uses `django_filters`. Each list view reads `request.query_params` directly inside its own +`get`/`get_context_data` and applies whatever filters it defines. `GET /api/leads/` +(`backend/leads/views/lead_views.py:105-160`) is representative of how rich these can get: + +| Query parameter | Matches | +| --- | --- | +| `name` | `first_name` or `last_name`, case-insensitive contains | +| `salutation` | case-insensitive contains | +| `source` | exact | +| `assigned_to` | repeatable; `assigned_to__id__in` | +| `status` | exact | +| `tags` | repeatable; `tags__id__in` | +| `city` | case-insensitive contains | +| `email` | case-insensitive contains | +| `rating` | exact | +| `search` | `first_name`, `last_name`, `company_name` or `email`, case-insensitive contains, OR'd together | +| `created_at__gte`, `created_at__lte` | date range | +| `close_date__gte`, `close_date__lte` | date range | +| `cf_` | custom field `` equals the given value (`custom_fields__contains`) | + +For example, `GET /api/leads/?status=assigned&source=partner&search=acme` — `status` and `source` +are exact matches against `LEAD_STATUS` and `LEAD_SOURCE` (`backend/common/utils.py:50-67`: +statuses are `assigned`, `in process`, `converted`, `recycled`, `closed`; sources are `call`, +`email`, `existing customer`, `partner`, `public relations`, `compaign`, `other`), so a filter value +outside those exact strings matches nothing rather than erroring. Other list endpoints define +their own, generally smaller, filter sets in the same style — there is no single reference for +"every filter the API supports"; read the view (or watch for a dedicated section on that resource's +page, where one exists) rather than assuming this table applies elsewhere. + +## Response shape + +There is no single response envelope used across the whole API. Three shapes recur, and which one +a given endpoint uses is part of that endpoint's own contract: + +**Mutations wrapped in an `error` flag.** The large majority of create/update/delete endpoints +return `{"error": bool, ...}`, checked explicitly rather than relying on the HTTP status code alone, +for example a successful lead create (`backend/leads/views/lead_views.py:402-404`): + +```json +{"error": false, "message": "Lead Created Successfully"} +``` + +and a failed one (`backend/leads/views/lead_views.py:406-408`): + +```json +{"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}} +``` + +The same `{"error": false, "tokens": [...]}` shape is used by the personal-access-token list in +[Tokens and API keys](tokens-and-api-keys.md#personal-access-tokens). + +**Bare serializer data.** Some newer endpoints skip the `error` wrapper entirely and return +`serializer.data` (or the created/updated object's serialization) directly, relying on +`serializer.is_valid(raise_exception=True)` and the HTTP status code to signal failure — for +example `MacroListCreateView.post` returns `Response(MacroSerializer(macro).data, status=201)` +(`backend/macros/views.py:136`). + +**Single-object detail responses nest the record, not return it bare.** `GET /api/leads/{id}/` +does not return a `Lead` object at the top level; it returns the record under `lead_obj` alongside +related data fetched in the same call: + +```json +{ + "lead_obj": {"id": "...", "first_name": "...", "...": "..."}, + "attachments": [...], + "comments": [...], + "users_mention": [...], + "assigned_data": [...] +} +``` + +(`backend/leads/views/lead_views.py:412-517`.) Don't assume a detail endpoint returns its resource +at the top level of the response; check the endpoint. + +## Content types + +`REST_FRAMEWORK` sets no `DEFAULT_PARSER_CLASSES`, so DRF's own defaults apply: a request body may +be `application/json`, `application/x-www-form-urlencoded`, or `multipart/form-data` +(`JSONParser`, `FormParser`, `MultiPartParser`, in that order). `multipart/form-data` is what every +endpoint that accepts a file upload alongside other fields needs — for example `POST /api/leads/` +reads an optional `lead_attachment` file straight off `request.FILES` in the same call that creates +the lead (`backend/leads/views/lead_views.py:330`), so a lead with an initial attachment is created +with one multipart request rather than a create-then-upload round trip. + +`REST_FRAMEWORK` sets no `DEFAULT_RENDERER_CLASSES` either, so both of DRF's defaults are live: +`JSONRenderer` for `application/json` (what every example on this page assumes, and what every +client integration should send `Accept: application/json` for) and `BrowsableAPIRenderer` for +`text/html` — request the same endpoint with a browser's default `Accept` header and DRF's +interactive HTML form is what comes back instead of JSON. diff --git a/docs/api/endpoint-index.md b/docs/api/endpoint-index.md new file mode 100644 index 00000000..027f3233 --- /dev/null +++ b/docs/api/endpoint-index.md @@ -0,0 +1,221 @@ +# Endpoint index + +Every route the API exposes, generated from the OpenAPI schema. The curated +pages in this section explain the endpoints you are most likely to need; this +table exists so nothing is invisible. + +Interactive versions of the same schema are served by a running backend at +`/swagger-ui/` and `/api/schema/redoc/`, with the raw document at `/schema/`. + +!!! note + This page is generated by `backend/scripts/generate_endpoint_index.py`. + Edit that script, not this file. + +| Path | Methods | +| --- | --- | +| `/api/accounts/` | GET, POST | +| `/api/accounts/attachment/{id}/` | DELETE | +| `/api/accounts/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/accounts/{id}/` | DELETE, GET, PATCH, POST, PUT | +| `/api/accounts/{id}/create_mail/` | POST | +| `/api/activities/` | GET | +| `/api/api-settings/` | GET, POST | +| `/api/api-settings/{id}/` | DELETE, GET, PATCH, PUT | +| `/api/auth/google/` | POST | +| `/api/auth/google/callback/` | POST | +| `/api/auth/magic-link/request/` | POST | +| `/api/auth/magic-link/verify-code/` | POST | +| `/api/auth/magic-link/verify/` | POST | +| `/api/auth/me/` | GET | +| `/api/auth/profile/` | GET | +| `/api/auth/refresh-token/` | POST | +| `/api/auth/switch-org/` | POST | +| `/api/boards/` | GET, POST | +| `/api/boards/columns/{column_pk}/tasks/` | GET, POST | +| `/api/boards/tasks/{id}/` | DELETE, PUT | +| `/api/boards/{board_pk}/columns/` | GET, POST | +| `/api/boards/{id}/` | DELETE, GET, PATCH, PUT | +| `/api/business-hours/calendar/` | GET, PUT | +| `/api/business-hours/calendar/{id}/` | GET, PUT | +| `/api/business-hours/calendar/{id}/holidays/` | POST | +| `/api/business-hours/calendar/{id}/holidays/{hid}/` | DELETE | +| `/api/cases/` | GET, POST | +| `/api/cases/analytics/agents/` | GET | +| `/api/cases/analytics/backlog/` | GET | +| `/api/cases/analytics/drilldown/` | GET | +| `/api/cases/analytics/export/` | GET | +| `/api/cases/analytics/frt/` | GET | +| `/api/cases/analytics/mttr/` | GET | +| `/api/cases/analytics/service/` | GET | +| `/api/cases/analytics/sla/` | GET | +| `/api/cases/approval-rules/` | GET, POST | +| `/api/cases/approval-rules/{id}/` | DELETE, GET, PUT | +| `/api/cases/approvals/` | GET | +| `/api/cases/approvals/{id}/approve/` | POST | +| `/api/cases/approvals/{id}/cancel/` | POST | +| `/api/cases/approvals/{id}/reject/` | POST | +| `/api/cases/attachment/{id}/` | DELETE | +| `/api/cases/bulk/delete/` | POST | +| `/api/cases/bulk/update/` | POST | +| `/api/cases/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/cases/csat/aggregate/` | GET | +| `/api/cases/escalation-policies/` | GET, POST | +| `/api/cases/escalation-policies/{id}/` | DELETE, GET, PUT | +| `/api/cases/import/commit/` | POST | +| `/api/cases/import/preview/` | POST | +| `/api/cases/inbound/{mailbox_id}/` | POST | +| `/api/cases/kanban/` | GET | +| `/api/cases/mailboxes/` | GET, POST | +| `/api/cases/mailboxes/{id}/` | DELETE, GET, PUT | +| `/api/cases/pipelines/` | GET, POST | +| `/api/cases/pipelines/{id}/` | DELETE, GET, PUT | +| `/api/cases/pipelines/{pipeline_pk}/stages/` | POST | +| `/api/cases/pipelines/{pipeline_pk}/stages/reorder/` | POST | +| `/api/cases/reopen-policy/` | GET, PUT | +| `/api/cases/routing-rules/` | GET, POST | +| `/api/cases/routing-rules/{id}/` | DELETE, GET, PUT | +| `/api/cases/routing-rules/{id}/test/` | POST | +| `/api/cases/solutions/` | GET, POST | +| `/api/cases/solutions/{id}/` | DELETE, GET, PATCH, PUT | +| `/api/cases/solutions/{id}/publish/` | POST | +| `/api/cases/solutions/{id}/unpublish/` | POST | +| `/api/cases/stages/{id}/` | DELETE, PUT | +| `/api/cases/watching/` | GET | +| `/api/cases/{id}/` | DELETE, GET, PATCH, POST, PUT | +| `/api/cases/{id}/activities/` | GET | +| `/api/cases/{id}/close-with-children/` | POST | +| `/api/cases/{id}/link/` | POST | +| `/api/cases/{id}/merge/{into_id}/` | POST | +| `/api/cases/{id}/move/` | PATCH | +| `/api/cases/{id}/request-approval/` | POST | +| `/api/cases/{id}/solution-suggestions/` | GET | +| `/api/cases/{id}/solutions/` | DELETE, POST | +| `/api/cases/{id}/solutions/{solution_pk}/` | DELETE, POST | +| `/api/cases/{id}/time-entries/` | GET, POST | +| `/api/cases/{id}/time-entries/start/` | POST | +| `/api/cases/{id}/time-summary/` | GET | +| `/api/cases/{id}/tree/` | GET | +| `/api/cases/{id}/unmerge/` | POST | +| `/api/cases/{id}/watch/` | DELETE, POST | +| `/api/cases/{id}/watchers/` | GET | +| `/api/contacts/` | GET, POST | +| `/api/contacts/attachment/{id}/` | DELETE | +| `/api/contacts/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/contacts/import/commit/` | POST | +| `/api/contacts/import/preview/` | POST | +| `/api/contacts/{id}/` | DELETE, GET, PATCH, POST, PUT | +| `/api/custom-fields/` | GET, POST | +| `/api/custom-fields/{id}/` | DELETE, GET, PUT | +| `/api/dashboard/` | GET | +| `/api/dashboard/today/` | GET | +| `/api/documents/` | GET, POST | +| `/api/documents/{id}/` | DELETE, GET, PATCH, PUT | +| `/api/invoices/` | GET, POST | +| `/api/invoices/attachments/{id}/` | DELETE | +| `/api/invoices/comments/{id}/` | DELETE, PUT | +| `/api/invoices/estimates/` | GET, POST | +| `/api/invoices/estimates/{id}/` | DELETE, GET, PUT | +| `/api/invoices/estimates/{id}/convert/` | POST | +| `/api/invoices/estimates/{id}/pdf/` | GET | +| `/api/invoices/estimates/{id}/send/` | POST | +| `/api/invoices/from-opportunity/{opportunity_id}/` | POST | +| `/api/invoices/from-time-entries/` | POST | +| `/api/invoices/products/` | GET, POST | +| `/api/invoices/products/{id}/` | DELETE, GET, PUT | +| `/api/invoices/recurring/` | GET, POST | +| `/api/invoices/recurring/{id}/` | DELETE, GET, PUT | +| `/api/invoices/recurring/{id}/toggle/` | POST | +| `/api/invoices/reports/aging/` | GET | +| `/api/invoices/reports/dashboard/` | GET | +| `/api/invoices/reports/revenue/` | GET | +| `/api/invoices/templates/` | GET, POST | +| `/api/invoices/templates/{id}/` | DELETE, GET, PUT | +| `/api/invoices/{id}/` | GET, PUT | +| `/api/invoices/{id}/cancel/` | POST | +| `/api/invoices/{id}/duplicate/` | POST | +| `/api/invoices/{id}/mark-paid/` | POST | +| `/api/invoices/{id}/pdf/` | GET | +| `/api/invoices/{id}/send/` | POST | +| `/api/invoices/{invoice_id}/attachments/` | POST | +| `/api/invoices/{invoice_id}/comments/` | POST | +| `/api/invoices/{invoice_id}/line-items/` | GET, POST | +| `/api/invoices/{invoice_id}/line-items/{id}/` | DELETE, PUT | +| `/api/invoices/{invoice_id}/payments/` | GET, POST | +| `/api/invoices/{invoice_id}/payments/{id}/` | DELETE | +| `/api/leads/` | GET, POST | +| `/api/leads/attachment/{id}/` | DELETE | +| `/api/leads/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/leads/create-from-site/` | POST | +| `/api/leads/kanban/` | GET | +| `/api/leads/pipelines/` | GET, POST | +| `/api/leads/pipelines/{id}/` | DELETE, GET, PUT | +| `/api/leads/pipelines/{pipeline_pk}/stages/` | POST | +| `/api/leads/pipelines/{pipeline_pk}/stages/reorder/` | POST | +| `/api/leads/stages/{id}/` | DELETE, PUT | +| `/api/leads/upload/` | POST | +| `/api/leads/{id}/` | DELETE, GET, PATCH, POST, PUT | +| `/api/leads/{id}/move/` | PATCH | +| `/api/macros/` | GET, POST | +| `/api/macros/{id}/` | DELETE, GET, PATCH, PUT | +| `/api/macros/{id}/render/` | POST | +| `/api/notifications/` | GET | +| `/api/notifications/read-all/` | POST | +| `/api/notifications/stream/` | GET | +| `/api/notifications/{id}/` | DELETE | +| `/api/notifications/{id}/read/` | POST | +| `/api/opportunities/` | GET, POST | +| `/api/opportunities/aging-config/` | GET, PUT | +| `/api/opportunities/attachment/{id}/` | DELETE | +| `/api/opportunities/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/opportunities/goals/` | GET, POST | +| `/api/opportunities/goals/leaderboard/` | GET | +| `/api/opportunities/goals/{id}/` | DELETE, GET, PUT | +| `/api/opportunities/kanban/` | GET | +| `/api/opportunities/{id}/` | DELETE, GET, PATCH, POST, PUT | +| `/api/opportunities/{id}/move/` | PATCH | +| `/api/opportunities/{opportunity_id}/line-items/` | GET, POST | +| `/api/opportunities/{opportunity_id}/line-items/{line_item_id}/` | DELETE, GET, PUT | +| `/api/org/` | GET, POST | +| `/api/org/api-key/` | GET, POST | +| `/api/org/settings/` | GET, PATCH | +| `/api/org/tokens/` | GET | +| `/api/org/tokens/{id}/` | DELETE | +| `/api/org/{id}/` | GET, PATCH, PUT | +| `/api/packs/` | GET | +| `/api/packs/sample-data/` | DELETE | +| `/api/packs/{pack_id}/apply/` | POST | +| `/api/profile/` | GET, PATCH | +| `/api/profile/tokens/` | GET, POST | +| `/api/profile/tokens/{id}/` | DELETE | +| `/api/public/csat/{token}/` | GET, POST | +| `/api/public/estimate/{token}/` | GET | +| `/api/public/estimate/{token}/accept/` | POST | +| `/api/public/estimate/{token}/decline/` | POST | +| `/api/public/estimate/{token}/pdf/` | GET | +| `/api/public/invoice/{token}/` | GET | +| `/api/public/invoice/{token}/pdf/` | GET | +| `/api/search/` | GET | +| `/api/tags/` | GET, POST | +| `/api/tags/{id}/` | DELETE, GET, PUT | +| `/api/tags/{id}/restore/` | POST | +| `/api/tasks/` | GET, POST | +| `/api/tasks/attachment/{id}/` | DELETE | +| `/api/tasks/comment/{id}/` | DELETE, PATCH, PUT | +| `/api/tasks/kanban/` | GET | +| `/api/tasks/pipelines/` | GET, POST | +| `/api/tasks/pipelines/{id}/` | DELETE, GET, PUT | +| `/api/tasks/pipelines/{pipeline_pk}/stages/` | POST | +| `/api/tasks/pipelines/{pipeline_pk}/stages/reorder/` | POST | +| `/api/tasks/stages/{id}/` | DELETE, PUT | +| `/api/tasks/{id}/` | DELETE, GET, PATCH, POST, PUT | +| `/api/tasks/{id}/move/` | PATCH | +| `/api/teams/` | GET, POST | +| `/api/teams/{id}/` | DELETE, GET, PATCH, PUT | +| `/api/time-entries/timesheet/` | GET | +| `/api/time-entries/unbilled/` | GET | +| `/api/time-entries/{id}/` | DELETE, PUT | +| `/api/time-entries/{id}/stop/` | POST | +| `/api/user/{id}/` | DELETE, GET, PATCH, PUT | +| `/api/user/{id}/status/` | POST | +| `/api/users/` | GET, POST | +| `/api/users/get-teams-and-users/` | GET | diff --git a/docs/api/errors.md b/docs/api/errors.md new file mode 100644 index 00000000..caf44fa7 --- /dev/null +++ b/docs/api/errors.md @@ -0,0 +1,143 @@ +# Errors + +How to tell a failed request from a successful one, and how to read the body of a failed one. +`REST_FRAMEWORK["EXCEPTION_HANDLER"]` in `backend/crm/settings.py` is set to DRF's own +`rest_framework.views.exception_handler` — unmodified, no custom subclass — so an exception DRF +itself raises (a bad permission check, a missing object, an unhandled `ValidationError`) comes back +in DRF's own default shape. A view that catches its own errors and builds a `Response` by hand, +which most views in this codebase do, can and often does return something else. Both are documented +below with real examples, because a client has to handle both. + +## Status codes + +`200` for a successful read or a successful write — many mutation endpoints return `200` rather +than `201` even on creation, wrapping the result in the `{"error": false, ...}` shape described in +[Conventions](conventions.md#response-shape) rather than relying on the status code alone (see +`POST /api/leads/`, `backend/leads/views/lead_views.py:402-404`). Some do use `201`: personal access +token creation, for instance, returns `201` (`backend/common/views/pat_views.py:69`). Neither is +"more correct" than the other here — check the endpoint. + +Beyond that: `400` for a request the server understood but rejected (validation, or a +request-shaped error like a missing required field); `401` for a request with no valid credentials +at all; `403` for a request that authenticated fine but isn't allowed to do this; `404` for a +resource that doesn't exist, or that exists but the caller must not be able to distinguish from not +existing — see [Not found versus forbidden](#not-found-versus-forbidden); `502` where a view depends +on an external service and that service is unreachable, for example the Google token exchange in +[Google OAuth](authentication.md#google-oauth-web) (`backend/common/views/auth_views.py:110-114`). +There is no application-level `429` — `REST_FRAMEWORK` sets no `DEFAULT_THROTTLE_CLASSES`, so DRF's +built-in throttling is not in play anywhere. The one place this codebase does rate-limit +(`POST /api/auth/magic-link/request/`, five tokens per address per hour) enforces it by silently +returning its normal `200` rather than a `429`, specifically so the response can't be used to +distinguish "rate limited" from "sent" from "invalid address" — see +[Magic links](authentication.md#magic-links). + +## Validation errors + +The dominant pattern in this codebase is not DRF's automatic one. Most views call +`serializer.is_valid()` without `raise_exception=True`, check the boolean themselves, and build the +error response by hand as `{"error": true, "errors": serializer.errors}` — this exact shape appears +well over 200 times across the backend. A `validate_` failure lands under that field's key, +DRF's normal behavior: + +```python +def validate_email(self, value): + ... + if duplicates.exists(): + raise serializers.ValidationError( + "Another lead in this organisation already uses that email address." + ) + return value +``` + +(`backend/leads/serializer.py:107-133`, `LeadCreateSerializer.validate_email` — the comment above it +explains this exists because a duplicate email used to reach the database as an unhandled +`IntegrityError` and come back as an opaque `500`.) A `POST /api/leads/` with a duplicate email +returns: + +```json +{"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}} +``` + +A `validate()` failure that isn't tied to one field lands under DRF's default +`non_field_errors` key (`NON_FIELD_ERRORS_KEY` is not overridden anywhere in this codebase), still +nested the same way: `{"error": true, "errors": {"non_field_errors": ["..."]}}`. + +A smaller, newer set of views instead call `serializer.is_valid(raise_exception=True)` — for +example `MacroListCreateView.post` (`backend/macros/views.py:123`) — and let DRF's exception handler +build the response itself, with no `error`/`errors` wrapper at all: + +```json +{"email": ["Another lead in this organisation already uses that email address."]} +``` + +Both shapes exist in the API today. Check the specific endpoint's page (or the view, if it isn't +documented yet) rather than assuming one; do not assume the presence or absence of an `error` key +tells you whether the request succeeded — the status code is what does that. + +## Authorization errors + +Authentication and authorization failures go through DRF's permission machinery unmodified, so they +always come back as `{"detail": ""}`, whether the message is DRF's own default or one this +codebase set: + +```json +{"detail": "Authentication credentials were not provided."} +``` + +```json +{"detail": "Organization context is required. Please login again."} +``` + +The second is `HasOrgContext.message` (`backend/common/permissions.py:11-41`), one of the two +permission classes almost every org-scoped view lists — `permission_classes = (IsAuthenticated, +HasOrgContext)`, and often a role check on top such as `IsOrgAdmin` +(`backend/common/permissions.py:44-59`, `"You must be an organization administrator to perform this +action."`). Whether a failure comes back as `401` or `403` is not decided by which permission class +failed — DRF's `APIView.permission_denied()` raises `NotAuthenticated` (`401`) whenever no +authenticator on the request actually succeeded, and `PermissionDenied` (`403`, carrying the failing +permission class's `message`) whenever one did but the permission check itself failed. In practice: +no credentials, or invalid ones → `401`; a valid, authenticated session that isn't allowed to do +this → `403`. + +## Not found versus forbidden + +The rule that holds everywhere an object is looked up by id: a record outside the caller's org +returns `404`, the same as a record that never existed at all, never `403`. Every such lookup +filters on `org=request.profile.org` (or the RLS-protected queryset equivalent) before matching the +id, so a request for another org's record simply finds nothing — +`get_object_or_404(Macro, pk=pk, org=request.profile.org)` (`backend/macros/views.py:148`, repeated +at `:168` for the plain `GET`) is one of dozens of examples of the same shape. +`invoices/permissions.py` states the reasoning explicitly in its own module docstring: + +``` +1. Does this record exist *in the caller's org*? -> 404 if not, so that a + caller in another org cannot probe for record IDs. +2. May this caller act on it? -> 403 if not. +``` + +(`backend/invoices/permissions.py:5-9`.) A `403` response inherently confirms the record exists — +it's not possible to be forbidden from acting on nothing — so reserving `403` for "exists, but you +may not do this" and `404` for everything else is what stops an id becoming an oracle a caller in +another org can probe with. + +Within a single org, whether a permission failure still returns `403` or drops all the way to `404` +depends on what the resource is. Invoices and estimates keep step 2 above as a real `403`: an org +member who is neither the record's creator, an assignee, nor an admin is refused with +`{"error": true, "message": "Permission denied"}` (`backend/invoices/permissions.py:80-84`) — a +colleague can tell the invoice exists, just not read or change it. A personal (as opposed to +org-scope) macro goes further and hides itself completely from everyone except its owner and org +admins, `404` even for a same-org caller, precisely to avoid the invoice case's disclosure: + +```python +if macro.scope == Macro.SCOPE_PERSONAL and macro.owner_id != request.profile.id: + # A personal macro you don't own is invisible to you — the list and + # GET hide it (404). The write verbs must not confirm it exists via + # a 403 either, or the id space leaks which rows are somebody else's + # personal macros. Mirror the GET: 404. + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) +``` + +(`backend/macros/views.py:156-164`.) Both are deliberate, documented choices for the resource in +question, not an inconsistency to paper over — when you add an authorization check to a new +endpoint, decide which of the two your resource needs (does an org-mate being aware this record +exists leak anything?) rather than defaulting to whichever is easier to write. diff --git a/docs/api/invoices.md b/docs/api/invoices.md new file mode 100644 index 00000000..965d36fb --- /dev/null +++ b/docs/api/invoices.md @@ -0,0 +1,315 @@ +# Invoices and estimates + +Authenticated routes in `backend/invoices/api_urls.py`, all mounted under `/api/invoices/...`. **The +DRF views live in `backend/invoices/api_views.py` — not `views.py`**, which is a legacy, +non-API module in this app. Anonymous client-portal routes are in +`backend/invoices/public_urls.py`, served by `backend/invoices/public_views.py`, mounted at +`/api/public/...`. Authorization for every authenticated invoice/estimate/recurring-invoice endpoint +is centralized in `backend/invoices/permissions.py`. See [Conventions](conventions.md) for pagination, +filtering and response-shape rules assumed rather than repeated here, and [Errors](errors.md) for the +404-vs-403 principle this page's own module docstring states almost verbatim. + +This page covers invoices, estimates, line items and the public portal — the endpoints this +documentation task scopes. Payments, products, templates, recurring invoices, dashboards/reports, and +invoice-from-opportunity/from-time-entries are also part of this module (`api_urls.py`) but are out of +scope for this page. + +## One access rule for invoices, estimates and recurring invoices + +`invoices/permissions.py` states a single rule, reused by all three record types via +`get_invoice_or_error`/`get_estimate_or_error`/`get_recurring_or_error` +(`invoices/permissions.py:101-122`): + +``` +1. Does this record exist in the caller's org? -> 404 if not +2. May this caller act on it? -> 403 if not +``` + +— the exact two-step [Errors: Not found versus forbidden](errors.md#not-found-versus-forbidden) quotes +as this module's own docstring. Step 2, `has_object_access` (`:32-58`), grants access to an org admin, +a Django superuser, the record's creator, or any profile in its `assigned_to` set — there is no +separate, narrower rule for write vs. delete the way [Cases](cases.md#a-cases-read-write-and-delete-rules-differ-on-purpose) +has; one rule covers read, write and delete alike for invoices and estimates. + +**The two comparisons inside that rule are against deliberately different types.** `created_by` is a +`User` FK (from `AuditModel`), so it is compared against `request.user`; `assigned_to` holds `Profile` +rows, so it is compared against `request.profile` (`:42-58`). The module docstring calls out that +getting this backwards — comparing `request.profile` to `created_by`, as the estimate views used to — +is not merely always-`False` the way the same mistake is elsewhere in this codebase: because it reaches +the ORM as `Q(created_by=)`, Django raises `ValueError` on the type mismatch, and the +non-admin estimate list answered `500` rather than an empty or wrong list. That specific defect is +fixed in the current source (`_get_or_error`/`has_object_access` as shown), but it is a concrete example +of why this codebase keeps insisting a permission check be provably tested in both directions. + +## Invoices + +`GET /api/invoices/` (`InvoiceListView.get`, `invoices/api_views.py:176-194`) hand-builds a response +with the same `count`/`next`/`previous`/`results` key names DRF's `get_paginated_response()` would +produce, plus a `totals` block computed over every invoice the caller can see (not just the page or the +current filter). This is the hand-rolled shape, not the DRF-generated one — +[Conventions](conventions.md#pagination) and [Cases](cases.md#solutions) both name +`GET /api/cases/solutions/` as the only endpoint in this API that actually calls +`get_paginated_response()`; this view reaches the same four key names by building the dict itself: + +```json +{ + "count": 41, + "next": null, + "previous": null, + "results": [ { "...": "InvoiceListSerializer" } ], + "totals": { + "count": 41, "outstanding": "12500.00", "overdue": "3200.00", + "due_this_month": "4100.00", "paid_this_quarter": "9800.00", + "draft": "600.00", "action_needed": 5 + } +} +``` + +`totals` is computed over `Invoice.objects.filter(pk__in=queryset.values("pk"))` rather than the +visibility-scoped queryset directly (`_totals`, `:196-235`) — the comment explains why: a non-admin's +queryset joins `assigned_to` (a many-to-many), so summing across that join would multiply an invoice's +amount by its number of assignees; re-querying by `pk__in` drops the join before the `Sum`. +`action_needed` is a **count**, not an amount — invoices still `Draft`, plus anything unpaid and past +`due_date` (`:209`). A non-admin caller sees only invoices they created or are assigned to (`:96-103`, +the same rule [above](#one-access-rule-for-invoices-estimates-and-recurring-invoices) applies at list +scope). + +Filters (`filter_queryset`, `:105-174`): `search` (matches `invoice_title`, `invoice_number`, +`client_name` or `client_email`, contains, capped to 100 characters), `status` (exact), +`account`/`contact`/`opportunity` (exact id), `assigned_to` (exact id), `created_by` (exact id), +`issue_date_gte`/`issue_date_lte`, `due_date_gte`/`due_date_lte`, `cf_`, and `sort` — whitelisted +to `created_at`/`due_date`/`issue_date`/`total_amount`/`status`, each optionally `-`-prefixed +(`:163-172`); an unrecognized `sort` value is silently ignored, leaving the default `-created_at`. + +`POST /api/invoices/` (`:237-292`) is validated by `InvoiceCreateSerializer` +(`invoices/serializer.py:542-618`; see [Fields](#fields)). On success (`201`): + +```json +{"error": false, "message": "Invoice created successfully", "invoice": { "...": "InvoiceSerializer" }} +``` + +Creating an invoice queues `create_invoice_history` (an audit-trail Celery task) and, if the invoice +already has assignees, `send_email` to notify them (`:261-278`). + +`GET /api/invoices/{id}/` (`InvoiceDetailView.get`, `:295-341`) returns +`{"invoice": ..., "attachments": [...], "comments": [...], "history": [...], "custom_field_definitions": [...]}`. +`PUT /api/invoices/{id}/` (`:343-400`) accepts a **partial** body (`partial=True` is passed even though +this is `PUT`, `:376`) through the same `InvoiceCreateSerializer`. **`InvoiceDetailView` defines only +`get` and `put` — no delete method of any kind exists for an invoice**; an invoice is retired via +`POST /api/invoices/{id}/cancel/`, never removed. (Contrast [Estimates](#estimates), which does support +a hard delete.) + +Three lifecycle actions, each `get_invoice_or_error`-gated and each a `POST` with no request body: + +- `POST /api/invoices/{id}/send/` (`InvoiceSendView.post`, `:403-440`) refuses to re-send a `Paid` or + `Cancelled` invoice (`400`); moves `Draft` → `Sent`, stamps `sent_at`/`is_email_sent`, and queues + `send_invoice_to_client`. +- `POST /api/invoices/{id}/mark-paid/` (`InvoiceMarkPaidView.post`, `:443-481`) records a `Payment` — + it defaults `amount` to the current `amount_due`, `payment_method` to `OTHER`, and `payment_date` to + today when the caller omits them, but routes the whole thing through + `PaymentCreateSerializer` (`invoices/serializer.py:365-396`) either way, so a payment amount of `0` + or one exceeding the outstanding balance is still rejected (`400`). +- `POST /api/invoices/{id}/cancel/` (`InvoiceCancelView.post`, `:563-599`) refuses an already-`Cancelled` + invoice and refuses a `Paid` one (`400` either way) — cancelling is for money that will never be + collected, not for reversing a completed payment. + +`POST /api/invoices/{id}/duplicate/` (`InvoiceDuplicateView.post`, `:484-560`) copies the invoice's +client/billing/discount/tax/template fields and every line item into a new `Draft` invoice with a fresh +`invoice_number`, `issue_date` reset to today, and no payments — `201` with the new +`InvoiceSerializer` payload. + +`GET /api/invoices/{id}/pdf/` (`InvoicePDFView.get`, `:602-631`) streams +`application/pdf`; a missing PDF backend answers `503`, any other generation failure `500`. + +## Estimates + +The same list/detail/lifecycle shape as invoices, with one structural difference: **an estimate can be +hard-deleted; an invoice cannot** (see [above](#invoices)). + +`GET /api/invoices/estimates/` (`EstimateListView.get`, `:1027-1064`) — same hand-built +`count`/`next`/`previous`/`results` shape as the invoice list above, without a `totals` block. Filters: +`status`, `account`, `search` (matches +`estimate_number`, `title` or `client_name`), `cf_`. Visibility follows the same rule as invoices +— non-admin sees only estimates they created or are assigned to. + +`POST /api/invoices/estimates/` (`:1066-1100`) is validated by `EstimateCreateSerializer` +(`invoices/serializer.py:892-1018`; see [Fields](#fields)). **Unlike `InvoiceCreateSerializer`, +`EstimateCreateSerializer` does not mark `status` read-only** — nothing stops an authenticated, +same-org caller with write access from `POST`ing or `PUT`ing an estimate directly into +`status: "Accepted"`. Doing so does **not** run any of the checks or side effects the public accept +flow enforces (see [Public portal endpoints](#public-portal-endpoints)): no expiry check, and none of +`accepted_by_name`/`accepted_by_email`/`accepted_ip`/`accepted_user_agent` get populated, even though +the model comment above those fields calls acceptance "an authorisation" that the app records who gave. +This is reported here, not fixed, per this documentation task's scope. + +`GET/PUT/DELETE /api/invoices/estimates/{id}/` (`EstimateDetailView`, `:1103-1187`) — `DELETE` is a +real hard delete (`estimate.delete()`, `:1182`), gated by the same access rule as read/write. + +`POST /api/invoices/estimates/{id}/convert/` (`EstimateConvertView.post`, `:1189-1266`) creates a new +`Draft` invoice from the estimate's client/billing/discount/tax fields and line items, then sets +`estimate.status = "Accepted"`, `accepted_at = now()`, and `estimate.converted_to_invoice = invoice` +(`:1253-1257`). **This is the field that stops a second conversion from duplicating the invoice**: the +view's very first check is `if estimate.converted_to_invoice: return 400` (`:1200-1204`), before +anything else runs. Any client rendering an estimate list needs to read this field, not just `status`, +to decide whether to offer "raise an invoice" — +`EstimateListSerializer.converted_to_invoice` (`invoices/serializer.py:832,846,866-870`) is on the list +row specifically so the worklist can tell "accepted, not yet billed" apart from "already billed" without +a second request per row; the class docstring is explicit that omitting it "would offer to raise a +second invoice for an estimate that already has one." + +`POST /api/invoices/estimates/{id}/send/` (`EstimateSendView.post`, `:1269-1291`) moves `Draft` → `Sent` +and queues `send_estimate_to_client`. `GET /api/invoices/estimates/{id}/pdf/` +(`EstimatePDFView.get`, `:1294-1334`) streams the PDF the same way the invoice one does. + +## Line items + +Two ways line items reach the database, with different validation coverage. + +**Nested in an invoice create/update.** `InvoiceCreateSerializer`'s `line_items` +(`InvoiceLineItemCreateSerializer`, many, `invoices/serializer.py:288-304`) is written inside the same +request as the invoice itself; `validate()` additionally rejects any line item whose `product` belongs +to a different org (`:689-704` — the comment notes the nested serializer's own `product` field is an +unscoped `PrimaryKeyRelatedField` over every org's products, so this cross-org check has to happen at +the parent serializer, where org context exists). On `PUT`, sending `line_items` **replaces** the set — +existing rows are deleted and recreated (`InvoiceCreateSerializer.update`, `:731-753`); omitting the key +entirely leaves existing line items untouched. + +**Standalone, via `POST /api/invoices/{invoice_id}/line-items/`** +(`InvoiceLineItemListView.post`, `invoices/api_views.py:653-679`) and +**`PUT`/`DELETE /api/invoices/{invoice_id}/line-items/{id}/`** +(`InvoiceLineItemDetailView`, `:682-751`). Both route through `InvoiceLineItemCreateSerializer` +directly, with no wrapping parent-serializer `validate()` — and **neither has an application-code check +that `product` belongs to the caller's org**: `InvoiceLineItemCreateSerializer` declares no `product` +field of its own (`invoices/serializer.py:288-304`), so DRF derives a plain +`PrimaryKeyRelatedField(queryset=Product.objects.all())` — every org's products — and both views use it +raw, unlike the *nested* path, which does check (`InvoiceCreateSerializer.validate()`, `:689-704`; grep +confirms this is the only `product`-org check anywhere in `invoices/serializer.py`). This is a real gap +against [the ORM-filter contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) — +the explicit application-level filter that page calls non-optional is missing here — but it is not an +unconditional cross-tenant read. `product` (the table, along with +`invoice_line_item`) is itself RLS-protected: both are in `ORG_SCOPED_TABLES` +(`common/rls/__init__.py:97`), with policy enabled by +`common/migrations/0008_enable_rls_product_invoice_line_item.py`. Under the non-superuser database role +[PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md) mandates, the unscoped +`Product.objects.all()` queryset sees zero rows for a foreign +org's id under RLS's fail-safe default, and the request answers `400` ("invalid pk") rather than +attaching the row — the missing ORM filter is caught by the safety net. Under the *default* +configuration — `DBUSER` unset, which falls back to the Postgres superuser, and therefore bypasses RLS +entirely, per [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) +— nothing stops it: the row attaches, and `InvoiceLineItemSerializer.product_name` +(`source="product.name"`, `:254,263`) discloses that other org's product name on every subsequent read. +Whether this is exploitable in a given deployment is exactly the question "is `DBUSER` a superuser?" +answers — which is also why it's worth fixing regardless of how any one deployment is configured: the +ORM filter is the contract, RLS is the net, and this endpoint is missing the contract. Reported here, +not fixed, per this documentation task's scope. + +Every write to a line item (either path) recalculates and saves the parent invoice's totals +immediately afterward (`invoice.recalculate_totals(); invoice.save()`) — a client does not need a +separate call to refresh `total_amount`/`amount_due` after adding, editing or removing a line item. + +`InvoiceLineItemCreateSerializer.Meta.fields` (`invoices/serializer.py:291-303`): +`product`, `name`, `description`, `quantity`, `unit_price`, `discount_type`, `discount_value`, +`tax_rate`, `order`. Every one of these is optional — `product` is nullable, `name` is `blank=True, +null=True`, `description` is `blank=True`, `quantity` defaults `1`, `unit_price`/`discount_value`/ +`tax_rate`/`order` all default `0`, and `discount_type` is `blank=True` over `DISCOUNT_TYPES` +(`PERCENTAGE`, `FIXED`) (`invoices/models.py:512-565,71-74`). Posting `{}` is valid and creates a +line item with `quantity=1`, `unit_price=0`. **Estimate line items work identically** — +`EstimateLineItemCreateSerializer` has the same field list (`invoices/serializer.py:306-321`) — except +there is no standalone endpoint for them at all: estimate line items are only ever written nested +inside `POST`/`PUT /api/invoices/estimates/{id}/`, and `EstimateCreateSerializer.validate()` +(`:969-980`) has **no application-level product-org check whatsoever** — not even the +parent-serializer-level check `InvoiceCreateSerializer` has. The same RLS caveat applies here as above: +`product` is RLS-protected, so this is a missing-ORM-filter gap caught by the safety net under a +correctly-configured (non-superuser) database role, and a real cross-tenant read under the default, +superuser one. + +## Public portal endpoints + +**Mounted at `/api/public/...`, not under `/api/invoices/...`** — `backend/crm/urls.py:23`: +`path("api/public/", include("invoices.public_urls", ...))`. Every view here sets +`authentication_classes = []` and `permission_classes = []` (`invoices/public_views.py`) — these +endpoints are genuinely anonymous, reachable by anyone holding the URL, by design (an emailed invoice +or estimate link). + +**Resolving the org happens before the RLS context is set, from an unscoped token lookup — this is the +whole reason the endpoint works at all under RLS.** `_resolve_org_context(token, resource_type)` +(`invoices/public_views.py:30-46`) calls `common.portal_tokens.resolve_portal_org`, which hashes the +raw URL token (`sha256`) and looks it up in `common.models.PortalAccessToken` — a small table carrying +no RLS policy — to get an `org_id`, then calls `set_rls_context(org_id)`. Only *after* that does the +view run its own `Invoice`/`Estimate` query. An unknown or malformed token leaves the RLS context +empty; the subsequent scoped query then returns nothing under RLS's fail-safe default, and the caller +gets the same `404` a disabled link would give — a stranger probing token values learns nothing about +whether one is real. [Architecture: Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#portal-tokens) +documents this mechanism in full, including why the lookup table has to be unscoped in the first place +(you cannot set `app.current_org` until you know the org, and you cannot look up the row that would +tell you the org until the context is set) — read that page for the general pattern; this page is the +concrete instance of it for invoices and estimates. Every endpoint below additionally requires +`public_link_enabled=True` on the record, independent of the token resolving at all — an admin can +revoke a previously-shared link without rotating the token. + +- `GET /api/public/invoice/{token}/` (`PublicInvoiceView.get`, `:61-163`) — returns the invoice's + client-facing fields, billing address, line items and payment history. First view stamps `viewed_at` + and flips `Sent` → `Viewed`. +- `GET /api/public/invoice/{token}/pdf/` (`PublicInvoicePDFView.get`, `:166-206`) — same PDF the + authenticated endpoint generates, `include_payments=True`. +- `GET /api/public/estimate/{token}/` (`PublicEstimateView.get`, `:209-301`) — same shape, for + estimates; also stamps `viewed_at` on first view. +- `GET /api/public/estimate/{token}/pdf/` (`PublicEstimatePDFView.get`, `:304-344`). +- `POST /api/public/estimate/{token}/accept/` (`PublicEstimateAcceptView.post`, `:347-430`) — the + authenticated `status` write does not exist for the anonymous caller, so acceptance is its own + endpoint with its own rules, all enforced server-side because "the frontend is not a trust boundary" + (comment at `:359-363`): the estimate must currently be `Sent` or `Viewed` (`400` otherwise); it must + not be expired (`estimate.is_expired`, checked against `expiry_date`, `400` with the expiry date in + the message if it is — enforced here for the first time; the model comment notes `expiry_date` was + previously "stored and printed on the estimate but enforced nowhere"); and the request body must + supply `name` and a syntactically valid `email` (`400` otherwise). On success, the view records who + accepted: `accepted_by_name`, `accepted_by_email` (truncated to the column's max length), `accepted_ip` + (best-effort, from `X-Forwarded-For` or `REMOTE_ADDR` — spoofable, treated as evidence, not proof), + and `accepted_user_agent`, alongside `status = "Accepted"` and `accepted_at`. +- `POST /api/public/estimate/{token}/decline/` (`PublicEstimateDeclineView.post`, `:433-468`) — same + `Sent`/`Viewed` precondition, no identity capture, sets `status = "Declined"` and `declined_at`. + +## Fields + +`InvoiceCreateSerializer.Meta.fields` (`invoices/serializer.py:558-618`) is what `POST /api/invoices/` +and `PUT /api/invoices/{id}/` accept. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `invoice_title` | string, max 100 | **required** | | +| `account_id` | uuid | **required** | Must belong to the caller's org; the model itself allows null, but the serializer makes it mandatory | +| `contact_id` | uuid | **required** | Must belong to the caller's org; must belong to `account_id` if both given | +| `opportunity_id` | uuid | optional | Must belong to the caller's org | +| `template_id` | uuid | optional | Must belong to the caller's org — without this check a cross-org template's branding/markup would render on this invoice's PDF | +| `client_name`, `client_email`, `client_phone` | string | optional | Denormalized for the PDF and portal; both `client_name`/`client_email` default `""` | +| `billing_*` / `client_*` address fields | string | optional | `billing_country`/`client_country` are one of `COUNTRIES` | +| `discount_type` | one of `PERCENTAGE`, `FIXED` | optional | | +| `discount_value`, `tax_rate`, `shipping_amount` | decimal | optional | Default `0` | +| `currency` | one of `CURRENCY_CODES` | optional | Defaults `USD` | +| `issue_date` | date | optional | Defaults today | +| `due_date` | date | optional | | +| `payment_terms` | one of `PAYMENT_TERMS` | optional | Defaults `NET_30` | +| `reminder_enabled`, `reminder_days_before`, `reminder_days_after`, `reminder_frequency` | — | optional | | +| `public_link_enabled` | boolean | optional | Gates the portal endpoints independently of the token; defaults `true` | +| `notes`, `terms` | text | optional | | +| `billing_period`, `po_number` | string | optional | | +| `line_items` | list | optional | See [Line items](#line-items) | +| `status` | one of `INVOICE_STATUS` | **read-only** | Always created `Draft`; changed only via `send`/`mark-paid`/`cancel` | + +`invoice_number` is not part of the serializer at all — `Invoice.save()` generates and assigns it +server-side the first time the row is saved (`invoices/models.py:384-411`). + +`EstimateCreateSerializer.Meta.fields` (`invoices/serializer.py:904-928`) mirrors the invoice shape +closely: `title` (**required**), `account_id`/`contact_id` (**required**, org-validated), +`opportunity_id` (optional, org-validated), the same client/address/discount/tax/currency/notes/terms +fields, `expiry_date` (optional — this is what the public accept endpoint checks), `public_link_enabled`, +and `line_items`. **`status` is writable here** — see the caveat in [Estimates](#estimates). Like +invoices, `estimate_number` is server-generated on first save, not part of the serializer. + +`GET` responses on both (`InvoiceSerializer`/`EstimateSerializer`, `invoices/serializer.py:444-539, +873-889`) additionally return, but never accept as input: `id`, `subtotal`, `discount_amount`, +`tax_amount`, `total_amount`, `amount_paid`, `amount_due` (all computed by +`recalculate_totals()`), `sent_at`/`viewed_at`/`paid_at`/`cancelled_at` (invoice) or +`sent_at`/`viewed_at`/`accepted_at`/`declined_at`/`accepted_by_*` (estimate), `public_token`, +`public_url`, `created_by`, `created_at`, `updated_at`, and — on the estimate only — +`converted_to_invoice` (see [Estimates](#estimates)). diff --git a/docs/api/leads.md b/docs/api/leads.md new file mode 100644 index 00000000..6c34ba18 --- /dev/null +++ b/docs/api/leads.md @@ -0,0 +1,246 @@ +# Leads + +Routes in `backend/leads/urls.py`, views split across `leads/views/lead_views.py` (list and +detail) and `leads/views/lead_interactions.py` (comment and attachment editing). See +[Conventions](conventions.md) for pagination, filtering and response-shape rules this page assumes +rather than repeats, and [Errors](errors.md) for the 404-vs-403 principle referenced below. + +## List leads + +`GET /api/leads/` (`LeadListView.get`, `backend/leads/views/lead_views.py:246-248`) returns two +separately paginated sections rather than one list — open leads and closed leads — because the view +excludes converted leads from the queryset entirely and then splits what's left on `status`: + +```python +queryset = ( + self.model.objects.filter(org=self.request.profile.org) + .exclude(status="converted") + ... +) +... +queryset_open = queryset.exclude(status="closed") +... +queryset_close = queryset.filter(status="closed") +``` + +(`lead_views.py:90-98,166,187`.) A non-admin caller only sees leads assigned to them or created by +them (`lead_views.py:99-103`); an admin or superuser sees every lead in the org. The response follows +the hand-rolled envelope [Conventions](conventions.md#pagination) describes for this exact endpoint: + +```json +{ + "per_page": 10, + "page_number": [1], + "open_leads": {"leads_count": 42, "open_leads": [ { "...": "LeadSerializer" } ], "offset": 10}, + "totals": {"count": 42, "unworked_over_a_week": 5}, + "close_leads": {"leads_count": 8, "close_leads": [ { "...": "..." } ], "offset": null}, + "contacts": [{"id": "...", "first_name": "..."}], + "status": [["assigned", "Assigned"], ["in process", "In Process"], ["converted", "Converted"], ["recycled", "Recycled"], ["closed", "Closed"]], + "source": [["call", "Call"], ["email", "Email"], ["existing customer", "Existing Customer"], ["partner", "Partner"], ["public relations", "Public Relations"], ["compaign", "Campaign"], ["other", "Other"]], + "tags": [{"...": "..."}], + "users": [{"id": "...", "user__email": "..."}], + "countries": [["GB", "United Kingdom"], ["AF", "Afghanistan"]], + "industries": [["ADVERTISING", "ADVERTISING"]] +} +``` + +`page_number` is worth calling out: the view builds it as `(int(self.offset / 10) + 1,)` +(`lead_views.py:178`) — a one-element Python tuple, trailing comma and all — which the JSON renderer +turns into a one-element array like `[1]`, not a bare integer. `totals.unworked_over_a_week` counts +leads in the filtered, org-scoped `open` queryset whose `last_contacted` (or, absent that, creation +date) is more than 7 days old (`UNWORKED_AFTER_DAYS`, `lead_views.py:58,60-86`) — not a stored status, +just a computed staleness signal. + +**`source` accepts only the values in `LEAD_SOURCE`** (`backend/common/utils.py:59-67`): `call`, +`email`, `existing customer`, `partner`, `public relations`, `compaign`, `other`. There is no +`website` value in this enum — that spelling belongs to a completely different, uppercase vocabulary +(`Opportunity.lead_source`, using `SOURCES`; see [Opportunities](opportunities.md#stages)). Note also +that `compaign` is not a typo in this documentation — it is the literal stored value for "Campaign" +leads. A client that sends `source=campaign` (the correctly-spelled word) gets zero rows back, not an +error, because `?source=` is an exact match (`lead_views.py:115-116`), and a client that tries to +*create* a lead with `source: "campaign"` gets a validation error, because `campaign` is not one of +the choices `Lead.source` accepts (`leads/models.py:75-77`). + +Filters beyond `source` and `status` — `name`, `salutation`, `assigned_to`, `tags`, `city`, `email`, +`rating`, `search`, `created_at__gte/lte`, `close_date__gte/lte`, `cf_` — are documented in full, +with exact match-vs-contains semantics for each, in +[Conventions: Filtering and search](conventions.md#filtering-and-search), which uses this exact +endpoint as its example. + +## Create a lead + +`POST /api/leads/` (`LeadListView.post`, `lead_views.py:273-409`) accepts `multipart/form-data` (for +an optional `lead_attachment` file, read straight off `request.FILES`, `lead_views.py:331`) or JSON. +The body is validated by `LeadCreateSerializer` (`leads/serializer.py:88-224`); see +[Fields](#fields) below for exactly which keys it accepts. On success (`200`, not `201` — see +[Errors: Status codes](errors.md#status-codes)): + +```json +{"error": false, "message": "Lead Created Successfully"} +``` + +Sending `"status": "converted"` on create runs the lead straight through +`leads.services.convert_lead_to_account` and returns a different shape instead: + +```json +{ + "error": false, + "message": "Lead Converted Successfully", + "account_id": "", + "contact_id": "", + "opportunity_id": "" +} +``` + +A validation failure returns `400` with `{"error": true, "errors": {...}}` +(`lead_views.py:406-409`), the shape [Errors](errors.md#validation-errors) documents. Two validators +worth knowing about before you integrate: + +- **Email uniqueness is case-insensitive per org**, and only checked when an email is actually + supplied (`validate_email`, `leads/serializer.py:107-134`): + `{"error": true, "errors": {"email": ["Another lead in this organisation already uses that email address."]}}`. +- **Email becomes required, but only when `status` is `"converted"`** — `LeadCreateSerializer.__init__` + flips `self.fields["email"].required = True` exactly in that case, having otherwise made + `first_name`, `last_name` and `salutation` optional regardless of what the `Lead` model itself + requires (`leads/serializer.py:100-104`). Every other field on this serializer is optional; see + [Fields](#fields). + +`tags`, `contacts`, `teams` and `assigned_to` are **not** part of `LeadCreateSerializer` — the view +reads each straight off the request body after the serializer succeeds, resolved against +`org=request.profile.org` once parsed. They are **not** handled uniformly, and the gap matters because +this same endpoint accepts `multipart/form-data` (above) — see [Fields](#fields) for the exact +per-verb, per-field table and the concrete failure a multipart request hits. + +## Retrieve, update, delete + +`GET /api/leads/{id}/` (`LeadDetailView.get`, `lead_views.py:568-571`) looks the lead up scoped to the +caller's org (`get_object`, `:416-417` — a lead in another org is a `404`, same as one that never +existed, matching the cross-org rule [Errors](errors.md#not-found-versus-forbidden) states generally). +It then calls `assert_lead_access` (`:419-443`): **an admin or superuser sees any lead; anyone else +must be the lead's creator or one of its assignees, or the request is refused with a `403`** — not a +`404` — even though `LeadListView` would have hidden that same lead from that same user entirely. +[Errors: Not found versus forbidden](errors.md#not-found-versus-forbidden) doesn't name leads at +all — its own text says the within-org choice between `403` and `404` "depends on what the resource +is," naming invoices (`403`) and personal macros (`404`) as its two worked examples — so treat this +paragraph, not that page, as the source for what leads actually does: there is no comment or test in +`leads/` recording this as a deliberate disclosure decision the way there is for invoices or macros; +it's simply the behavior as written. The response nests the record under `lead_obj`, matching the +shape [Conventions](conventions.md#response-shape) documents for this exact endpoint, alongside +`attachments`, `comments`, `users_mention`, `assigned_data`, `users`, `users_excluding_team`, `source`, +`status`, `teams`, `countries` and `custom_field_definitions` (`lead_views.py:513-540`). + +`PUT /api/leads/{id}/` (`:674-823`) and `PATCH /api/leads/{id}/` (`:847-1064`) both enforce the same +creator-or-assignee-or-admin rule before touching anything, and both can convert the lead when +`status` (or, for `PATCH` only, `is_converted`) is set. `leads/workflow.py` is explicit that this +guard covers less than "any already-closed-off status": it deliberately separates two ideas that used +to be conflated under one name (`leads/workflow.py:11-30`). **`IRREVERSIBLE_STATUSES` is `{"converted"}` +alone** (`:34`) — converting an already-`converted` lead a second time is refused with `400` rather +than silently creating a second `Opportunity` against the same `Account`. `CLOSING_STATUSES` is the +separate, deliberately-*reversible* set `{"closed"}` (`:37`; "reopening a closed lead is ordinary +work," per the module docstring at `:24`), and `recycled` is in neither set. **Converting a `closed` +or `recycled` lead succeeds** and +runs the full conversion, same as converting an `assigned` or `in process` one — there is no guard +against it. `PATCH`'s conversion branch returns before `LeadCreateSerializer` (and its +`validate_status`, which enforces the same `IRREVERSIBLE_STATUSES`-only rule) ever runs, so it repeats +the check inline instead (`lead_views.py:875-894`). Both verbs re-attach `tags`/`contacts`/`teams`/ +`assigned_to` from the request body when present, but — like `POST` — not identically across the four +fields; see [Fields](#fields) for the exact per-verb table. Success returns +`{"error": false, "message": "Lead updated Successfully"}` (or the conversion shape above); failure +returns `{"error": true, "errors": {...}}`. + +`DELETE /api/leads/{id}/` (`:1080-1095`) allows an admin, a superuser, or the lead's own creator +(`request.profile.user == self.object.created_by`) — anyone else gets `403` — and additionally +requires the lead's org to match the caller's, even though `get_object` already guarantees that by +construction. + +## Comments and attachments + +Adding a comment or an attachment to a lead is not a separate endpoint — it's the same +`POST /api/leads/{id}/` route the create endpoint uses, just with an existing `pk` +(`LeadDetailView.post`, `lead_views.py:589-651`). The caller must be an admin/superuser, the lead's +creator, or one of its assignees (`:599-610`). Send `comment` (text) and/or `lead_attachment` (a +multipart file) — either or both — and the response echoes the same `lead_obj`/`attachments`/`comments` +shape as `GET`. + +Editing or removing an *existing* comment goes through a dedicated route: +`PUT` / `PATCH` / `DELETE /api/leads/comment/{id}/` (`LeadCommentView`, +`backend/leads/views/lead_interactions.py:60-183`) — restricted to an admin, a superuser, or the +comment's own author (`request.profile == obj.commented_by`; `commented_by` is a `Profile` foreign +key, so this comparison is correct, unlike some `created_by` comparisons elsewhere in this codebase — +see [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks)). + +`DELETE /api/leads/attachment/{id}/` (`LeadAttachmentView.delete`, `lead_interactions.py:203-221`) has +a real gap, documented in +[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks): +the lookup is `self.model.objects.get(pk=pk)` with **no `org=` filter at all** +(`lead_interactions.py:204`), so the id space for attachment deletion is not scoped to the caller's +org by the ORM query — only PostgreSQL RLS, if correctly configured for the deployment, stands between +this endpoint and a cross-tenant delete. The ownership check on the same endpoint +(`request.profile.user == self.object.created_by`, `:208`) is correct — it compares a `User` to a +`User` — so once an attachment is found, only its uploader, an admin, or a superuser can delete it; +the defect is specifically the missing org scope in the lookup, not the permission check. + +## Fields + +`LeadCreateSerializer.Meta.fields` (`leads/serializer.py:178-213`) is what `POST /api/leads/`, +`PUT /api/leads/{id}/` and `PATCH /api/leads/{id}/` accept. Every field the `Lead` model itself +declares is optional (`blank=True, null=True`, or has a default) except `first_name` and `last_name`, +which have `null=True` but not `blank=True` at the model level and would default to required under +DRF's normal derivation — the serializer's `__init__` explicitly overrides both to `required=False` +(`leads/serializer.py:102-103`, `leads/models.py:49-50`). The practical result: **a lead can be +created with no name at all**, as long as `status` isn't `"converted"` (which requires `email` +instead — see [Create a lead](#create-a-lead)). + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `title` | string | optional | Free text, e.g. "Enterprise Deal" | +| `salutation` | string | optional | | +| `first_name` | string | optional | Forced optional by the serializer; see above | +| `last_name` | string | optional | Forced optional by the serializer; see above | +| `email` | string | **required only when `status: "converted"`** | Must be unique per org, case-insensitive | +| `phone` | string | optional | | +| `job_title` | string | optional | | +| `website` | string | optional | Free text, not validated as a URL | +| `linkedin_url` | string | optional | Validated as a URL | +| `status` | one of `LEAD_STATUS` | optional | `assigned`, `in process`, `converted`, `recycled`, `closed` | +| `source` | one of `LEAD_SOURCE` | optional | `call`, `email`, `existing customer`, `partner`, `public relations`, `compaign`, `other` — no `website` | +| `industry` | one of `INDCHOICES` | optional | e.g. `SOFTWARE` | +| `rating` | `HOT` \| `WARM` \| `COLD` | optional | | +| `opportunity_amount` | decimal | optional | Non-negative (DB constraint) | +| `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted and `opportunity_amount` is set | +| `probability` | integer, 0–100 | optional | | +| `close_date` | date | optional | | +| `address_line`, `city`, `state`, `postcode` | string | optional | | +| `country` | one of `COUNTRIES` | optional | | +| `last_contacted`, `next_follow_up` | date | optional | | +| `description` | text | optional | | +| `company_name` | string | optional | | +| `is_active` | boolean | optional | Defaults `true` | + +Not part of the serializer, but accepted in the same request body and resolved by the view on +`POST`/`PUT`/`PATCH` (each a list of ids, org-scoped): `tags`, `contacts`, `teams`, `assigned_to`; plus +`lead_attachment` (a multipart file). These four id-list fields are **not parsed the same way on every +verb**, and the difference is a real trap for a client that also uploads a file (multipart fields +arrive as strings, never as JSON arrays): + +| Field | `POST /api/leads/` | `PUT /api/leads/{id}/` | `PATCH /api/leads/{id}/` | +| --- | --- | --- | --- | +| `tags` | plain `id__in=data.get("tags")` — no string handling (`lead_views.py:318-323`) | same gap (`:715-721`) | `json.loads`'d if a string, `{"id": …}` unwrapped (`:981-995`) | +| `contacts` | same gap (`:325-329`) | `json.loads`'d if a string (`:732-745`) | `json.loads`'d if a string (`:997-1011`) | +| `teams` | `json.loads`'d if a string (`:340-350`) | same (`:747-758`) | same (`:1013-1027`) | +| `assigned_to` | `json.loads`'d if a string (`:352-364`) | same (`:760-773`) | same (`:1029-1043`) | + +Sending `tags` or `contacts` as a JSON-encoded string in the same request as a `multipart/form-data` +`lead_attachment` upload — on `POST`, or on `PUT` for `tags` specifically — hands Django's `id__in` +lookup a raw string. Django iterates it character by character; every character fails `UUIDField` +parsing, and the request dies as an unhandled `500` rather than a clean `400` — the same failure mode +already documented for a different field at `contacts/views.py:105-108`. Sending those two fields as a +real JSON body (no file) works fine, because DRF parses a JSON request body into an actual list before +the view ever reads it. + +`GET /api/leads/{id}/` and `GET /api/leads/` additionally return, but never accept as input: `id`, +`created_by`, `created_at`, `updated_at`, `is_sample` (server-set only — see +`leads/serializer.py:80-85`), `lead_attachment` and `lead_comments` (nested read-only serializations of +this lead's attachments and comments — `leads/serializer.py:64-65`), `stage` and `kanban_order` +(managed by the Kanban endpoints, not this page), and `custom_fields` (validated separately against the +org's `CustomFieldDefinition` rows). diff --git a/docs/api/opportunities.md b/docs/api/opportunities.md new file mode 100644 index 00000000..e4a557b2 --- /dev/null +++ b/docs/api/opportunities.md @@ -0,0 +1,186 @@ +# Opportunities + +Routes in `backend/opportunity/urls.py`, views split across `opportunity/views/opportunity_views.py` +(list and detail) and `opportunity/views/kanban_views.py` (stage board and move). See +[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than +repeated here. + +## List opportunities + +`GET /api/opportunities/` (`OpportunityListView.get`, `backend/opportunity/views/opportunity_views.py:272-274`) +returns a single paginated list plus org-wide `totals` computed over the whole filtered queryset, not +just the current page (`get_totals`, `:73-110`): + +```json +{ + "opportunities_count": 23, + "totals": {"count": 23, "amount_sum": "184500.00", "weighted_sum": "97250.00", "stalled_count": 2}, + "offset": 10, + "per_page": 10, + "page_number": [1], + "opportunities": [ { "...": "OpportunitySerializer" } ], + "accounts_list": [ { "...": "AccountSerializer" } ], + "contacts_list": [ { "...": "ContactSerializer" } ], + "tags": [ { "...": "..." } ], + "stage": [["PROSPECTING", "Prospecting"], ["QUALIFICATION", "Qualification"]], + "lead_source": [["NONE", "NONE"], ["CALL", "CALL"], ["WEBSITE", "WEBSITE"]], + "currency": [["USD", "USD, Dollar"]] +} +``` + +`weighted_sum` is the forecast — `SUM(amount * probability / 100)` — as opposed to `amount_sum`, +which is what every open and closed deal in the filtered set is worth at face value +(`:90-98`). `stalled_count` uses the same per-stage aging thresholds as `?rotten=true` below and as +`Opportunity.get_aging_status()`; see [Stages](#stages). A non-admin caller only sees deals they +created or are assigned to (`:119-131`). + +Filters: `name`, `search` (both `icontains` against `name` only — `search` here does **not** OR +across multiple fields the way it does on [Leads](leads.md#list-leads)); `account` (exact); `stage`, +`lead_source` (**`__contains`, not exact** — see [Stages](#stages) for why this matters); `tags`, +`assigned_to` (repeatable, id lists); `created_at__gte/lte`, `closed_on__gte/lte`, +`amount__gte/lte`; `cf_`; and two boolean flags, `open=true` (excludes closed stages) and +`rotten=true` (stalled deals only) — see [Stages](#stages) (`opportunity_views.py:133-192`). + +## Create an opportunity + +`POST /api/opportunities/` (`OpportunityListView.post`, `opportunity_views.py:292-413`) is validated +by `OpportunityCreateSerializer` (`opportunity/serializer.py:199-297`); see [Fields](#fields). On +success (`200`): + +```json +{"error": false, "message": "Opportunity Created Successfully", "id": ""} +``` + +**`name` must be unique per org, case-insensitive** (`validate_name`, +`opportunity/serializer.py:213-228`). The serializer also enforces, at the API layer, the two rules +`Opportunity.clean()` states at the model layer — closed deals need a close date, and `CLOSED_WON` +deals need an amount (`validate()`, `:242-268`; see [Stages](#stages)) — because DRF's +`ModelSerializer` never calls `Model.clean()`, so without this the model's own rules would only be +enforced by unit tests against the model, never through the API. +`contacts`, `tags`, `teams` and `assigned_to` are resolved from the request body the same +list-of-ids way as [Leads](leads.md#create-a-lead) and [Accounts](accounts.md#create-an-account) +(`opportunity_views.py:317-376`); `opportunity_attachment` is accepted as a multipart file. + +## Retrieve, update, delete + +`GET /api/opportunities/{id}/` (`OpportunityDetailView.get`, `opportunity_views.py:661-736`) looks the +deal up scoped to the caller's org — unlike leads/accounts/contacts, this lookup returns `None` rather +than raising, so a missing or cross-org id comes back as an explicit `404` response +(`"error": true, "errors": "Opportunity not found."`, `:663-667`) instead of Django's +`get_object_or_404`. `assert_deal_access` (`:423-447`) then requires the caller to be an admin, the +deal's creator, or one of its assignees, or the request is refused with `403`. The response nests the +record under `opportunity_obj`, and also returns `comments`, `attachments`, `contacts`, +`users`, `stage`, `lead_source`, `currency`, `comment_permission`, `users_mention` and +`custom_field_definitions`. + +`PUT /api/opportunities/{id}/` (`:464-596`) and `PATCH /api/opportunities/{id}/` (`:831-953`) both +enforce `assert_deal_access`, and both stamp `closed_by = request.profile` and persist it with an +explicit `.save()` whenever the incoming `stage` is `CLOSED_WON` or `CLOSED_LOST` +(`:537-539`, `:942-944`) — a deal moved into a closed stage always records who closed it. Both clear +and re-add `contacts`/`tags`/`teams`/`assigned_to` from the request body the same way create does. +Success: `{"error": false, "message": "Opportunity Updated Successfully"}`. + +`DELETE /api/opportunities/{id}/` (`:612-637`) allows an admin, a superuser, or the deal's own creator +(`request.profile.user != self.object.created_by`, a `User`-to-`User` comparison, `:624-632`) — +anyone else gets `403`. + +## Stages + +**`stage` is a fixed, org-wide enum, not a configurable pipeline.** `Opportunity.stage` is a +`CharField` with `choices=STAGES` and `default="PROSPECTING"` +(`opportunity/models.py:54-56`; `STAGES` in `backend/common/utils.py:87-94`): +`PROSPECTING`, `QUALIFICATION`, `PROPOSAL`, `NEGOTIATION`, `CLOSED_WON`, `CLOSED_LOST`. There is no +`Pipeline`/`Stage` model behind it the way there is for the other three record types that have a +Kanban board in this API — `LeadPipeline`/`LeadStage` (`leads/models.py:229-330`), +`CasePipeline`/`CaseStage` (`cases/models.py:490,527`), and `Board`/`BoardColumn` for tasks +(`tasks/models.py:16,83`) are all per-org, admin-configurable. Opportunity's Kanban view says this +about itself in its own module docstring: "Status-based only (Opportunity has no Pipeline/Stage model +— it groups by the flat `stage` CharField)" (`opportunity/views/kanban_views.py:1-6`). Every org gets +the same six stages; there is no endpoint to add, rename or reorder them. + +Two consequences of moving a deal between stages, both enforced server-side (see +[Create an opportunity](#create-an-opportunity)): entering `CLOSED_WON` or `CLOSED_LOST` requires +`closed_on`; entering `CLOSED_WON` specifically also requires `amount` +(`CLOSED_STAGES`, `AMOUNT_REQUIRED_STAGES`, `opportunity/workflow.py:17-21`). `probability` is +auto-filled from a fixed per-stage default (`STAGE_PROBABILITIES`, `workflow.py:8-15`) whenever the +value on the record is `0` or unset (`Opportunity.save()`, `opportunity/models.py:262-264`) — sending +your own `probability` still works, this only fills the gap when you don't. + +**Filtering by stage is a substring match, not an exact one** — `?stage=` and `?lead_source=` both +compile to `__contains` (`opportunity_views.py:138-143`), unlike `Lead.status`/`Lead.source`, which +are exact matches (see [Leads](leads.md#list-leads)). `?stage=CLOSED` therefore matches both +`CLOSED_WON` and `CLOSED_LOST`; `?stage=closed` (lowercase) matches neither, because `__contains` on a +`CharField` is case-sensitive. + +`?rotten=true` on `GET /api/opportunities/` and the `stalled_count` in its `totals` both use the same +definition as `Opportunity.get_aging_status()`: a deal is stalled once it has sat in its current, +non-closed stage for at least `expected_days * 1.5` (`ROTTEN_MULTIPLIER`, `workflow.py:32`), where +`expected_days` comes from the org's `StageAgingConfig` for that stage if one exists, else a built-in +default (`DEFAULT_STAGE_EXPECTED_DAYS`, `workflow.py:24-29`; `stalled_filter`, +`opportunity_views.py:43-66`). + +`GET /api/opportunities/kanban/` (`OpportunityKanbanView.get`, `kanban_views.py:39-120`) returns one +column per `STAGES` value, applying the same org/role scoping and a subset of the list filters +(`search`, `account`, `assigned_to`, `tags`, `closed_on__gte/lte`): + +```json +{ + "mode": "status", + "pipeline": null, + "columns": [ + {"id": "PROSPECTING", "name": "Prospecting", "order": 1, "color": "#3B82F6", "stage_type": "open", "is_status_column": true, "wip_limit": null, "item_count": 4, "items": [ { "...": "OpportunityKanbanCardSerializer" } ]} + ], + "total_items": 23 +} +``` + +`"pipeline": null` is not a placeholder for missing data — it is always `null` here, precisely because +there is no pipeline object behind this board. + +`PATCH /api/opportunities/{id}/move/` (`OpportunityMoveView.patch`, `kanban_views.py:140-204`) +**requires `stage`** — `OpportunityMoveSerializer.stage` is a `ChoiceField` with no `required=False` +(`opportunity/serializer.py:362-367`, whose own docstring says so explicitly), and the view reads +`data["stage"]` unconditionally (`kanban_views.py:178`) — a reorder-only body with no `stage` key gets +a `400`, not a silent no-op reorder. When `stage` is sent, `kanban_order` can optionally come with it +to reorder within the (possibly new) column at the same time (fractional indexing, same convention as +tasks — averaging two neighbors' `kanban_order`, or offsetting by ±1000 with only one neighbor given). +Its own access check +intends to mirror `assert_deal_access` (admin, creator, or assignee) but the creator half is broken: +`is_owner = request.profile == opportunity.created_by` compares a `Profile` to `Opportunity.created_by`, +which is a `User` foreign key — the two are never equal, so a non-admin who created a deal but did not +also assign it to themselves cannot move it and gets `403` (`kanban_views.py:161-168`). This is one of +the two open `created_by` type-mismatch call sites in this codebase's `opportunity` app; see +[Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) +for the other and for why it hasn't been fixed as part of this documentation task. + +## Fields + +`OpportunityCreateSerializer.Meta.fields` (`opportunity/serializer.py:270-289`) is what +`POST /api/opportunities/`, `PUT /api/opportunities/{id}/` and `PATCH /api/opportunities/{id}/` +accept. Only `name` is required at the model level (`opportunity/models.py:46`, no `blank=True`). + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `name` | string | **required** | Unique per org, case-insensitive | +| `account` | uuid | optional | | +| `stage` | one of `STAGES` | optional | Defaults `PROSPECTING`; see [Stages](#stages) | +| `opportunity_type` | one of `OPPORTUNITY_TYPES` | optional | `NEW_BUSINESS`, `EXISTING_BUSINESS`, `RENEWAL`, `UPSELL`, `CROSS_SELL` | +| `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted | +| `amount` | decimal | **required if `stage: "CLOSED_WON"`** | Otherwise optional; non-negative (DB constraint) | +| `probability` | integer, 0–100 | optional | Auto-filled from `stage` if `0`/unset — see [Stages](#stages) | +| `closed_on` | date | **required if `stage` is `CLOSED_WON` or `CLOSED_LOST`** | Otherwise optional | +| `lead_source` | one of `SOURCES` | optional | Uppercase — `NONE`, `CALL`, `EMAIL`, `EXISTING CUSTOMER`, `PARTNER`, `PUBLIC RELATIONS`, `CAMPAIGN`, `WEBSITE`, `OTHER`. **Not** `Lead.source`'s `LEAD_SOURCE` — see [Leads](leads.md#list-leads) | +| `description` | text | optional | | +| `is_active` | boolean | optional | Defaults `true` | + +Not part of the serializer, but accepted in the same request and resolved by the view (see +[Create an opportunity](#create-an-opportunity)): `contacts`, `tags`, `teams`, `assigned_to` (each a +list of ids, org-scoped), and `opportunity_attachment` (a multipart file). + +`GET /api/opportunities/` and `GET /api/opportunities/{id}/` additionally return, but never accept as +input: `id`, `closed_by`, `created_by`, `created_at`, `org` (nested), `created_on_arrow`, +`amount_source` (`MANUAL` or `CALCULATED` — server-derived by `recalculate_amount()` from whether the +deal has line items, `opportunity/models.py:68-73,185-202`; not writable through this serializer), +`stage_changed_at`, `days_in_stage`, `aging_status` (`green`/`yellow`/`red`, from +`Opportunity.get_aging_status()`), `line_items`, `line_items_total`, and `custom_fields` (validated +separately against the org's `CustomFieldDefinition` rows). diff --git a/docs/api/tasks.md b/docs/api/tasks.md new file mode 100644 index 00000000..b923c3c2 --- /dev/null +++ b/docs/api/tasks.md @@ -0,0 +1,292 @@ +# Tasks + +Routes in `backend/tasks/urls.py`. CRUD, comments and attachments are in +`backend/tasks/views/task_views.py`; Kanban pipelines/stages and the move endpoint in +`backend/tasks/views/kanban_views.py`; Kanban **boards** — a separate feature — in +`backend/tasks/views/board_views.py`. Object-level authorization is centralized in +`backend/tasks/access.py`. See [Conventions](conventions.md) for pagination, filtering and +response-shape rules assumed rather than repeated here, and [Errors](errors.md) for the +404-vs-403 principle referenced below. + +## `Task.save()` calls `full_clean()` + +Before anything else, one fact that changes how you should read every write endpoint below: +**`Task.save()` explicitly calls `self.full_clean()`** (`tasks/models.py:402-404`). This is not the +default — [Architecture: Data model](../architecture/data-model.md#conventions) documents that most +models here (`Lead`, `Opportunity`, `Case`) define real validation in `clean()` that is never invoked +through the API, because their `save()` methods don't call `full_clean()`. `Task` is one of a small +handful that do (alongside `Comment.save()` and `Attachments.save()`, which enforce that a comment or +attachment's `org` matches its parent record's org — same page). The consequence is the opposite +problem: when `full_clean()` *does* run and finds a violation, it raises Django's own +`django.core.exceptions.ValidationError` — not DRF's — and [Errors](errors.md) states plainly, in its +own opening paragraph, that this codebase's `EXCEPTION_HANDLER` is DRF's default, unmodified, which +does not know how to render that exception. Unless the specific view code path catches it, a +model-level rule violation on a `Task` reaches the client as an unhandled **`500`**, not a clean `400`. + +The one rule `Task.clean()` currently enforces is "at most one parent entity" — a task may link to +at most one of `account`, `opportunity`, `case`, `lead` (`tasks/models.py:384-400`). The main +create/update surface guards against this twice: `TaskCreateSerializer.validate()` +(`tasks/serializer.py:331-365`) re-implements the same rule and returns a clean `400` with the field +named, *and* `TaskListView.post` / `TaskDetailView.put` / `TaskDetailView.patch` additionally wrap +`serializer.save()` in `try/except django.core.exceptions.ValidationError` as a backstop +(`_model_errors`, `tasks/views/task_views.py:56-66`, used at `:277-281,577-581,702-706`) — so for +`POST /api/tasks/`, `PUT /api/tasks/{id}/` and `PATCH /api/tasks/{id}/` specifically, this rule +reaches the client as `{"error": true, "errors": {"account": "..."}}` with `400` today, not a `500`. + +**Not every write path shares that backstop.** `PATCH /api/tasks/{id}/move/` +(`TaskMoveView.patch`, `tasks/views/kanban_views.py:253-318`) — the Kanban drag-and-drop endpoint — +sets `task.stage`, `task.status` and `task.kanban_order` directly and calls `task.save()` with no +`try/except` around it (`:310`) and without going through `TaskCreateSerializer` at all. `full_clean()` +validates every field on the instance, not only the ones a given endpoint touched, so any model-level +violation it finds here — on this task's existing `account`/`opportunity`/`case`/`lead` combination, or +any other `clean_fields()` rule — surfaces as the unhandled `500` described above. This isn't only a +theoretical exposure through the parent-entity fields: `TaskStage.maps_to_status` +(`tasks/models.py:257-263`) is a plain `CharField`, `blank=True, null=True`, with no `choices=` +constraint — free text an admin sets when configuring a pipeline stage, not validated against +`Task.STATUS_CHOICES` at all. When a task moves into a stage that has one set, +`if stage.maps_to_status: task.status = stage.maps_to_status` (`kanban_views.py:297-298`) assigns it +straight onto `task.status` — a field that *does* have `choices=` at the model level. A single typo'd +`maps_to_status` on one admin-configured stage turns every future move into that stage into an +unhandled `500` for every user, until an admin notices and fixes the stage. This endpoint has no +equivalent of the two-layer guard the main CRUD endpoints have. + +## List tasks + +`GET /api/tasks/` (`TaskListView.get`, `tasks/views/task_views.py:233-235`) returns one paginated +list. Visibility is `visible_tasks_qs` (`tasks/access.py:43-48`) — admin sees every task in the org; +anyone else sees only tasks they created or are assigned to. This is the same rule the detail view +enforces (`tasks/access.py` module docstring: "the list has to agree with the detail view or the queue +lies"). + +```json +{ + "tasks_count": 24, + "offset": 10, + "tasks": [ { "...": "TaskListSerializer" } ], + "totals": {"count": 24, "open": 18, "overdue": 3, "due_today": 1, "due_this_week": 5, "no_due_date": 6, "unassigned": 2}, + "status": [["New", "New"], ["In Progress", "In Progress"], ["Completed", "Completed"]], + "priority": [["Low", "Low"], ["Medium", "Medium"], ["High", "High"]], + "users": [{"id": "...", "user__email": "..."}], + "accounts_list": [ { "...": "AccountSerializer" } ], + "contacts_list": [ { "...": "ContactSerializer" } ] +} +``` + +(`tasks/views/task_views.py:130-209`.) `totals` is one aggregate query over the whole filtered, +visibility-scoped queryset, not the page — `overdue`/`due_today`/`due_this_week`/`no_due_date`/ +`unassigned` all additionally require the task still be open (`status != "Completed"`), so a task that +finished late does not count as overdue (`:145-167`). `accounts_list`/`contacts_list` are the full org +catalogues for the parent-entity picker; `?slim=true` omits both (`:203-209`). + +Filters, read directly from `request.query_params` +(`tasks/views/task_views.py:86-129`): `title` (contains), `status` (exact), `priority` (exact), +`assigned_to` (repeatable id list), `tags` (id list), `search` (matches `title`, contains — not a +combined search across other fields the way [Leads](leads.md#list-leads) or +[Contacts](contacts.md#list-contacts) define one), `due_date__gte`/`due_date__lte`, +`created_at__gte`/`created_at__lte`, `account`, `opportunity`, `case`, `lead` (each an exact parent-id +match), and `cf_` (custom field equals). + +## Create a task + +`POST /api/tasks/` (`TaskListView.post`, `tasks/views/task_views.py:252-349`) accepts JSON or +`multipart/form-data`, validated by `TaskCreateSerializer` (`tasks/serializer.py:315-389`; see +[Fields](#fields)). On success (`200`): `{"error": false, "message": "Task Created Successfully"}`. A +validation failure returns `400` with `{"error": true, "errors": {...}}` — including the "at most one +parent" rule and the model's own `full_clean()` backstop, both described [above](#tasksave-calls-full_clean). + +`account`, `opportunity`, `case` and `lead` are each scoped to the caller's org in +`TaskCreateSerializer.__init__` (`tasks/serializer.py:328-329`) — the comment there notes a plain +`ModelSerializer` would otherwise build each as a `PrimaryKeyRelatedField` over *every* row in the +table, and that a task in one org was proven to accept another org's account, with the list endpoint +then rendering that org's account name back to the requester. `created_by` is listed in +`Meta.fields` but is `read_only` (`:389`) — the comment explains that leaving it writable let a client +name any user in any org as a task's creator, which becomes a privilege-escalation vector the moment +the creator clause in `tasks/access.py` is consulted for anything (which it is — see +[Fields](#fields)). + +`contacts`, `teams`, `assigned_to` and `tags` are **not** part of `TaskCreateSerializer` — the view +resolves each from the request body after the serializer succeeds, org-scoped, `json.loads`'d when +sent as a string (the same shape [Cases](cases.md#create-a-case) uses). *Parsing* is consistent across +all four fields on every verb — tasks does not have the per-field inconsistency +[Leads](leads.md#fields) documents. *Writing* is a different story, and the same data-loss trap +[Cases](cases.md#fields) documents applies here verbatim: `POST` only adds +(`task_views.py:282,296,308,322`); `PUT` calls `.clear()` on all four **unconditionally**, before +checking whether the body mentioned them at all (`:582,597,610,625`), so a `PUT` that omits +`assigned_to` unassigns the task from everyone — including, via `visible_tasks_qs` +(`tasks/access.py:43-48`), removing it from those people's task lists entirely; `PATCH` clears a given +M2M only when its key is present in the body (`:709-758`). + +## Retrieve, update, delete + +`GET /api/tasks/{id}/` (`TaskDetailView.get`, `tasks/views/task_views.py:460-463`) org-scopes the +lookup (`get_task_or_404`, `tasks/access.py:51-65` — a malformed id or a cross-org task both answer +`404`) and enforces `assert_task_access` before building the response (`:365`) — raised, not returned, +specifically because a `Response` object handed back through this method used to be wrapped in a +second `Response` and crash with a `500` instead of answering `403` (comment at `:361-364`). The +response nests the record under `task_obj`, alongside `attachments`, `comments`, `users_mention`, +`assigned_data`, `custom_field_definitions`, `users` (admins see every active member; non-admins see +only other admins — the same shape [Cases](cases.md#list-cases) uses for its assignee picker), +`users_excluding_team` and `teams`. + +`PUT /api/tasks/{id}/` (`:545-650`) and `PATCH /api/tasks/{id}/` (`:667-781`) both enforce +`assert_task_access` — access here has only two tiers, not three the way a case does: **`access` +(read/write together) is admin · creator · assignee; `delete` is the narrower admin · creator** +(`tasks/access.py:1-10`). The module docstring is explicit about why a task collapses read and write +into one rule where a case does not: a case can have a *watcher*, someone following a ticket without +being handed it, and that extra read-only tier has no equivalent on a task — "everyone who may open one +may also work it." `contacts`/`teams`/`assigned_to`/`tags` are re-attached from the request body the +same way `POST` does. Success: `{"error": false, "message": "Task updated Successfully"}`. + +`DELETE /api/tasks/{id}/` (`:797-805`) calls `assert_task_delete_access` — admin or creator only, no +assignee exception, same reasoning as [Cases](cases.md#retrieve-update-delete): being assigned a task +is a reason to work it, not to erase it. + +## Comments and attachments + +The same shape as [Cases](cases.md#comments-and-attachments): `POST /api/tasks/{id}/` +(`TaskDetailView.post`, `tasks/views/task_views.py:481-528`) adds a `comment` and/or a +`task_attachment` file to an existing task, gated by `assert_task_access`. The comment on this method +notes a real trap: validating the comment through `CommentSerializer` fails *silently* here, because +that serializer requires `object_id` and `org`, neither of which the client sends — `is_valid()` +returns `False` and the intended write is simply skipped, so the view constructs the `Comment` directly +with Django's ORM instead of going through the serializer (`:488-500`). + +`PUT`/`PATCH`/`DELETE /api/tasks/comment/{id}/` (`TaskCommentView`, `:808-946`) edit or remove an +existing comment — admin, or the comment's own author (`request.profile == obj.commented_by`, a +`Profile`-to-`Profile` comparison, correct). **`PUT` cannot, in practice, edit a comment.** It builds +`CommentSerializer(obj, data=params)` with no `partial=True` (`:851`), and `CommentSerializer.Meta.fields` +includes the plain (not `read_only`) model fields `object_id` and `org` +(`common/serializer.py:239-250`) — a body of just `{"comment": "..."}`, everything the documented +`TaskCommentEditSwaggerSerializer` contract asks for, fails validation on the missing `object_id`/`org` +and answers `400`. Only `.patch()` (`partial=True`, `:896`) can actually change a comment's text. +Separately, because `object_id`/`org` genuinely are writable on `PATCH`, a comment's own author can +supply both there to repoint the comment at a different record, including one in another org or one +they cannot otherwise read — `Comment.clean()` (`common/models.py:299-314`) only catches the mismatched +case (`org` doesn't match the new target's `org`), and only because `Comment.save()` calls +`self.full_clean()` (`:316-318`), which turns that mismatch into an unhandled `500` rather than a `400` +(same failure mode as [above](#tasksave-calls-full_clean), on a different model); a *matching* cross-org +pair saves without error. Neither behavior is fixed here — reported per this documentation task's +scope. The identical `PUT`/`PATCH` pattern exists on [Cases](cases.md#comments-and-attachments)' own +comment-edit endpoint too. + +`DELETE /api/tasks/attachment/{id}/` (`TaskAttachmentView.delete`, `:949-1000`) is org-scoped +(`.filter(pk=pk, org=request.profile.org)`, `:975-977`) and its ownership check compares +`request.profile.user_id` to `self.object.created_by_id` (`:987`) — both fixed here. The comment on +this method documents that both defects (an unscoped lookup across every org's attachments, and a +`Profile`-to-`User` comparison that silently made the endpoint admin-only) were proven live before the +fix — see [Architecture: Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) +for the current, cross-app state of the same defect pattern in other modules. + +## Boards and columns + +**Kanban *boards* are a separate feature from a task's own `stage`/`pipeline` fields, with their own +model family (`Board`, `BoardColumn`, `BoardTask`, `BoardMember` — `tasks/models.py:16-194`) and their +own endpoints mounted at `/api/boards/...`, not under `/api/tasks/...`.** Confirmed in +`backend/common/app_urls/__init__.py:26`: `path("boards/", include((board_urlpatterns, "api_boards")))`, +where `board_urlpatterns` is imported from `tasks/urls.py:58-75`. A `BoardTask` card is not a `Task` +row — it has its own title/description/priority/assignees and only optionally references an +`account`/`contact`/`opportunity` for context (`tasks/models.py:116-195`); the `/api/tasks/kanban/`, +`/api/tasks/pipelines/...`, `/api/tasks/pipelines/{pipeline_id}/stages/` and `/api/tasks/stages/{id}/` +endpoints documented implicitly above (via `stage` in [Fields](#fields)) operate on real `Task` rows and +are a different Kanban system entirely. + +Access to a board is per-board membership, not org-wide: `Board.owner` plus `BoardMember` rows carry a +`role` of `owner`/`admin`/`member` (`tasks/models.py:43-81`). Every endpoint that acts on an *existing* +board checks membership before doing anything (the one exception is creating a board in the first +place — `POST /api/boards/` requires nothing beyond org membership, since there's no board to be a +member of yet); some further gate by role: + +| Endpoint | Who | +| --- | --- | +| `GET /api/boards/` | Any authenticated member of the org; results are owner-or-member boards only (`board_views.py:82-89`) | +| `POST /api/boards/` | Any org member; creates the board and, by default, three starter columns (`:126-157`) | +| `GET /api/boards/{id}/` | Board owner or member (`404` otherwise — not `403`, `:165-192`) | +| `PUT/PATCH /api/boards/{id}/` | Board `owner` or `admin` role (`:206-271`) | +| `DELETE /api/boards/{id}/` | Board owner only (`:282-299`) | +| `GET /api/boards/{board_id}/columns/` | Board owner or member (`:316-329`) | +| `POST /api/boards/{board_id}/columns/` | Board `owner` or `admin` role (`:355-367`) | +| `GET/POST /api/boards/columns/{column_id}/tasks/` | Any board member (no role check, `:413-455`) | +| `PUT /api/boards/tasks/{id}/` | Any board member (no role check, `:490-503`) | +| `DELETE /api/boards/tasks/{id}/` | Any board member — the role check lists `owner`/`admin`/`member`, which is every role that can exist (`:570-583`) | + +**A malformed board/column/card id is a `500`, not a `404`, on every endpoint in this table.** Every +lookup in `board_views.py` uses Django's `get_object_or_404` against a UUID primary key +(`:167,319,358,416,445,493,573`) — and `get_object_or_404` only catches the model's `DoesNotExist`, not +the `ValidationError`/`ValueError` a non-UUID string raises against a UUID field. This is the exact bug +class `get_task_or_404`/`get_case_or_404` (`tasks/access.py:51-65`, `cases/access.py:57-71`) exist to +prevent on the rest of this API; boards never adopted that helper. Not fixed here — reported per this +documentation task's scope. + +**There is no endpoint to update or delete a single column once created.** `board_urlpatterns` +(`tasks/urls.py:58-75`) defines only `GET`/`POST /api/boards/{board_id}/columns/` — a list-and-create +route — with no `columns/{id}/` detail path at all. A column's name, color, order or WIP `limit` cannot +be changed, and a column cannot be removed, through this API today. + +**`GET /api/boards/{id}/` does not return each column's cards.** `BoardSerializer.columns` uses +`BoardColumnListSerializer` (`tasks/serializer.py:109-121,123-143`), which reports only a `task_count` +per column, not the cards themselves. To read the actual cards, call +`GET /api/boards/{board_id}/columns/` separately, which uses the full `BoardColumnSerializer` and +nests nested `BoardTaskSerializer` cards under each column (`board_views.py:334-343`). + +`PUT /api/boards/tasks/{id}/` (`BoardTaskDetailView.put`, `board_views.py:490-559`) is also how a card +moves between columns: send `column` (validated to be a column of the same board — a column id from +another board, or from another org's board by construction, is a `400`, not a silent no-op, +`:512-529`) and/or `order`; the view renumbers both the source and target columns to a dense +`0..n-1` ordering after the move so drag positions survive a reload (`_resequence_column`, `:22-48`). + +`BoardTaskSerializer`'s write surface is deliberately narrow (`tasks/serializer.py:36-90`): a card +write may set `title`, `description`, `order`, `priority`, `due_date`, and assignees (via the +write-only `assigned_to_ids`, applied by the view after save and org-filtered). Everything else is +locked down, by two different mechanisms: `id`, `created_at`, `updated_at`, `completed_at`, `org`, +`column`, `created_by`, `updated_by`, `contact` and `opportunity` are all in `read_only_fields` +(`:67-78`) — `column` because moving a card is the dedicated `PUT` flow above, and the CRM-link FKs +because, unfiltered, they exposed the same cross-org `PrimaryKeyRelatedField` gap +`TaskCreateSerializer` was hardened against. `account` reaches the same outcome a different way: it's +overridden as a plain `SerializerMethodField()` (`:60`), not listed in `read_only_fields` at all — +overriding a model FK with a method field removes it from the write surface just as effectively, +per the field's own comment (`:82-84`). + +## Fields + +`TaskCreateSerializer.Meta.fields` (`tasks/serializer.py:367-389`) is what `POST /api/tasks/`, +`PUT /api/tasks/{id}/` and `PATCH /api/tasks/{id}/` accept. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `title` | string, max 200 | **required** | Explicitly forced `required=True` in `__init__` (`:321`), matching the model | +| `status` | one of `Task.STATUS_CHOICES` | **required** | `New`, `In Progress`, `Completed` — a different vocabulary from `Case.status`, see [Cases: Fields](cases.md#fields) | +| `priority` | one of `Task.PRIORITY_CHOICES` | **required** | `Low`, `Medium`, `High` — a different vocabulary from `Case.priority` | +| `due_date` | date | optional | | +| `description` | text | optional | | +| `account` | uuid | optional | Org-scoped; at most one of `account`/`opportunity`/`case`/`lead` may be set — see [above](#tasksave-calls-full_clean) | +| `opportunity` | uuid | optional | Same one-parent rule | +| `case` | uuid | optional | Same one-parent rule | +| `lead` | uuid | optional | Same one-parent rule | +| `created_by` | — | **read-only** | Listed in `Meta.fields`, but server-derived; see [Create a task](#create-a-task) | + +Not part of `TaskCreateSerializer`, but accepted in the same request body: `contacts`, `teams`, +`assigned_to`, `tags` (each a list of ids, org-scoped — see [above](#retrieve-update-delete) for how +*writing* them diverges across verbs); plus `task_attachment` (a multipart file) and `custom_fields` +(an object). Neither of the last two goes through the serializer either — the view reads +`custom_fields` straight off `request.data`, validates it against the org's +`CustomFieldDefinition` rows, and passes the cleaned result to `serializer.save(custom_fields=...)` +on all three write verbs (`task_views.py:256-276,556-574,681-699`); `task_attachment` is read off +`request.FILES` the same way it is on [Cases](cases.md#create-a-case). + +`GET /api/tasks/{id}/` returns the record via `TaskSerializer`; `GET /api/tasks/` returns each row via +the slimmer `TaskListSerializer`, which additionally drops `description`, `custom_fields`, `contacts` +and `teams` (compare `TaskSerializer.Meta.fields`, `tasks/serializer.py:287-309`, against +`TaskListSerializer.Meta.fields`, `:256-271`). **Two fields declared on `TaskSerializer` never actually +reach the client.** `task_attachment` and `task_comments` are declared as nested, read-only serializer +fields (`:280-281`, listed in `Meta.fields` at `:306-307`) — but `Task` has no attribute by either name. +There is no `GenericRelation` defined anywhere in this codebase (`grep -rn "GenericRelation"` outside +the virtualenv returns nothing), so DRF's attempt to read `task_attachment`/`task_comments` off the +instance raises `AttributeError`, which a read-only field with no explicit default converts into +`SkipField` — the keys are silently omitted from every response, not emitted empty. The comments and +attachments a client actually needs come from the separate top-level `attachments`/`comments` keys +`TaskDetailView.get` builds independently (`:421-430`, [above](#retrieve-update-delete)) — a field +being present in `Meta.fields` is not proof it is ever emitted; check what the model can actually +resolve. **`stage` and `kanban_order` are not returned by either `GET`, and are not in `Meta.fields` +on either serializer at all** — they exist only on `TaskKanbanCardSerializer` +(`:519-540`), which backs the Kanban-specific endpoints (`/api/tasks/kanban/`, +`/api/tasks/{id}/move/`), not the create/update endpoints this page covers. diff --git a/docs/api/tokens-and-api-keys.md b/docs/api/tokens-and-api-keys.md new file mode 100644 index 00000000..45785ae0 --- /dev/null +++ b/docs/api/tokens-and-api-keys.md @@ -0,0 +1,174 @@ +# Tokens and API keys + +Two credential types exist alongside the JWT session described in +[Authentication](authentication.md), for callers that need to authenticate without a browser or an +interactive sign-in: personal access tokens and the per-org API key. This page documents the CRUD +surface for both. For what each one is, how it's resolved, and how its blast radius compares to the +others, see [Architecture: Authentication](../architecture/authentication.md#personal-access-tokens) +and [Security hardening](../self-hosting/security-hardening.md#token-surfaces). + +## Personal access tokens + +A personal access token (`bcrm_pat_…`, `PersonalAccessToken` in `backend/common/models.py:854-907`) +authenticates as the profile that created it and inherits that profile's role and org in full. It +carries a `scopes` field, but nothing in the request-handling code enforces it — read the model's +own comment before assuming otherwise: + +```python +# NOTE: scopes are stored for forward-compatibility but are NOT enforced in +# Phase 1 — a token always inherits the owning profile's full role/permissions. +# Do not treat `scopes` as a trust boundary until enforcement lands. +``` + +(`backend/common/models.py:871-873`.) Set `scopes` if you like for your own bookkeeping; it does not +restrict what the token can do today. + +`GET /api/profile/tokens/` (`PersonalAccessTokenListCreateView`, +`backend/common/views/pat_views.py:33-47`) lists the caller's own tokens — filtered on +`profile=request.profile` as well as `org=request.profile.org`, since this table carries no RLS +policy of its own (it's looked up by `token_hash` before any org context exists, so the explicit +filter here is the only tenant boundary, not a belt over RLS's braces): + +```json +{ + "error": false, + "tokens": [ + { + "id": "", + "name": "laptop mcp", + "token_prefix": "bcrm_pat_abc1", + "scopes": [], + "expires_at": null, + "last_used_at": "2026-07-30T12:00:00Z", + "created_at": "2026-07-01T09:00:00Z", + "revoked_at": null + } + ] +} +``` + +`token_hash` is never serialized (`PersonalAccessTokenListSerializer`, +`backend/common/serializer.py:1054-1067`, whose `fields` is the exhaustive list above) — only +`token_prefix` identifies a token in any listing, on this endpoint or the admin one below. + +## Creating and revoking + +`POST /api/profile/tokens/` (same view, `:49-69`) creates a token for the caller: + +```json +{"name": "laptop mcp", "scopes": [], "expires_at": null} +``` + +`name` is required (non-empty, at most 255 characters); `scopes`, if present, must be a list of +strings (at most 32); `expires_at` is optional, but if present must be in the future — +`validate_expires_at` rejects a past timestamp with `"expires_at must be in the future."` +(`backend/common/serializer.py:1092-1097`). On success (`201`): + +```json +{ + "error": false, + "id": "", + "name": "laptop mcp", + "token_prefix": "bcrm_pat_abc1", + "scopes": [], + "expires_at": null, + "last_used_at": null, + "created_at": "2026-08-02T10:00:00Z", + "revoked_at": null, + "token": "bcrm_pat_abc1..." +} +``` + +The `token` field is the only time the raw value is ever returned — it is generated, hashed, and +the plaintext discarded; there is no way to retrieve it again later, only to revoke it and create a +new one. A validation failure returns `400` with `{"error": true, "errors": {...}}`, the same shape +described in [Errors](errors.md#validation-errors). + +`DELETE /api/profile/tokens/{id}/` (`PersonalAccessTokenDetailView`, `:72-86`) revokes a token — +sets `revoked_at`, it does not delete the row. It is scoped to `org=request.profile.org` **and** +`profile=request.profile` in the same lookup, so another user's token id returns `404`, not `403`: +revoking is unavailable to anyone but the token's own owner, and the lookup gives no sign a +different user's token even exists at that id. Revoking an already-revoked token is a no-op that +still returns `200`. + +## Organization token oversight + +`GET /api/org/tokens/` (`OrgAccessTokenListView`, `backend/common/views/pat_views.py:89-152`) is a +separate, admin-only endpoint — `permission_classes = (IsAuthenticated, HasOrgContext, +IsOrgAdmin)` — that lists every personal access token in the org, not just the caller's own. It is +deliberately kept apart from `profile/tokens/` above rather than folded into it with an +admin-widened filter, so the "a user manages only their own tokens" guard on the self-service +endpoint is never the thing that has to be loosened to support oversight: + +```json +{ + "error": false, + "tokens": [ + { + "id": "", + "name": "laptop mcp", + "token_prefix": "bcrm_pat_abc1", + "scopes": [], + "expires_at": null, + "last_used_at": "2026-07-30T12:00:00Z", + "created_at": "2026-07-01T09:00:00Z", + "revoked_at": null, + "owner": {"id": "", "name": "Jane Doe", "role": "USER", "is_active": false}, + "is_live": true + } + ], + "totals": {"count": 12, "live": 9, "orphaned": 2, "unused_90d": 3} +} +``` + +`owner.is_active: false` with `is_live: true` is the specific case this view exists to surface: a +token belonging to a profile that has since been deactivated. Deactivating a profile invalidates its +tokens as a side effect, not through any separate revocation step — +`resolve_valid_pat` (`backend/common/pat_auth.py:29-46`) checks `pat.profile.is_active` (and +`pat.org.is_active`) on every single use, so the token in the example above cannot actually +authenticate again unless the owner's profile is reactivated first. `"orphaned"` in `totals` counts +exactly these: still unrevoked (`is_live`), but dormant because their owner is gone, which is what an +admin doing offboarding cleanup would revoke explicitly with the endpoint below. + +`DELETE /api/org/tokens/{id}/` (`OrgAccessTokenDetailView`, `:155-176`) revokes any token in the +admin's own org, filtered only on `org=request.profile.org` (no `profile=` filter, unlike the +self-service delete above) — an admin can revoke a colleague's token, deactivated or not. A pk from +another org still `404`s. Revoking an already-revoked token is a no-op `200`, same as the +self-service endpoint. + +## Organization API keys + +Each org has exactly one API key (`Org.api_key`), managed through `GET`/`POST /api/org/api-key/` +(`OrgApiKeyView`, `backend/common/views/organization_views.py:244-326`), admin-only in both +directions — checked by hand in `_admin_org_or_error` (`:255-275`) rather than an `IsOrgAdmin` +permission class, but the same rule: `request.profile.role == "ADMIN"` or +`request.profile.is_organization_admin`. + +Unlike a personal access token, this key doesn't authenticate as any particular person — a request +bearing it in the `Token` header resolves to the org's first active `ADMIN` profile +(`backend/common/middleware/get_company.py:157-183`), so a leaked key is equivalent to a leaked +admin session for that org, not one user's session, and there's no way to scope it down. The org is +always taken from `request.profile.org`, never from anything client-supplied, so a caller can only +ever read or rotate their own org's key. + +`GET /api/org/api-key/`: + +```json +{"error": false, "api_key": ""} +``` + +`POST /api/org/api-key/` rotates it — generates a new key and immediately overwrites the old one: + +```json +{ + "error": false, + "message": "API key rotated. The previous key is no longer valid.", + "api_key": "" +} +``` + +The message is not just cosmetic: the old key stops authenticating the moment this call returns, +with no grace period. A non-admin caller gets `403` with +`{"error": true, "errors": "Only organization admins can access the API key"}` from either method; +a caller with no org context at all gets `400` with +`{"error": true, "errors": "Organization context required"}`. diff --git a/docs/architecture/authentication.md b/docs/architecture/authentication.md new file mode 100644 index 00000000..d4c9b8fe --- /dev/null +++ b/docs/architecture/authentication.md @@ -0,0 +1,206 @@ +# Authentication + +BottleCRM has three separate credential types — a short-lived JWT for interactive clients, a +personal access token for agents and scripts, and a per-org API key for server-to-server +integrations — and this page describes how each one proves identity and how the server decides +what it's allowed to do. The rule underneath all three is the same one stated in +[Multi-tenancy and RLS](multi-tenancy-and-rls.md): identity, org, and role are facts the server +derives and verifies, never values a client supplies. Nothing in a request body, query string, or +custom header changes who you are authenticated as. + +## Token model + +Interactive sign-in (Google OAuth and passwordless magic-link/OTP) issues a JSON Web Token pair +built on `djangorestframework-simplejwt`. `SIMPLE_JWT` in `backend/crm/settings.py` sets: + +```python +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=14), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + ... +} +``` + +Access tokens live one hour; refresh tokens live 14 days. Both are signed with `SECRET_KEY` itself +— there is no separate JWT signing key setting anywhere in this codebase, so rotating `SECRET_KEY` +invalidates every outstanding token immediately. See +[Security hardening](../self-hosting/security-hardening.md#secrets) for the operational +consequences of that. `rest_framework_simplejwt.token_blacklist` is in `INSTALLED_APPS` +specifically so that `RefreshToken.blacklist()` exists to call — without that app installed, +`BLACKLIST_AFTER_ROTATION` would be a silent no-op, which is exactly the kind of failure mode this +codebase's own comment on the `INSTALLED_APPS` entry calls out. + +Tokens are minted by `OrgAwareRefreshToken` (`backend/common/serializer.py`), a `RefreshToken` +subclass whose `for_user_and_org(user, org, profile=None)` classmethod embeds org and role claims +directly in the token payload (see [Claims](#claims)) so the frontend doesn't need a follow-up +`/api/auth/profile/` call just to learn which org or role it's operating as. + +Two other credential types exist alongside the JWT, covered later on this page: +[personal access tokens](#personal-access-tokens) (`bcrm_pat_…`, self-service, scoped to one +profile) and [organization API keys](#organization-api-keys) (one per org, admin-managed, +equivalent to an admin session). See +[Security hardening](../self-hosting/security-hardening.md#token-surfaces) for how their blast +radii compare. + +## Claims + +`OrgAwareRefreshToken.for_user_and_org` embeds, beyond simplejwt's own default claims +(`token_type`, `exp`, `iat`, `jti`, and the user id under `USER_ID_CLAIM = "user_id"`): + +- `user_email`, `user_name` (derived from the email, since `User` has no first/last name fields), + `user_profile_pic` +- `org_id`, `org_name` — only when an org is present; a token minted before the caller has picked + an org (see [Organization switching](#organization-switching)) carries neither +- `org_settings` — `default_currency`, `currency_symbol`, `default_country` +- `role` — only when a `Profile` was supplied, i.e. only once an org is known + +`org_id` is the claim that matters most: it's what `GetProfileAndOrg` +(`backend/common/middleware/get_company.py`) reads to resolve `request.org`, and by extension it's +what `RequireOrgContext` uses to set the PostgreSQL session variable every RLS policy checks — see +[Overview](overview.md#request-lifecycle). Critically, the middleware does not simply trust the +claim: it re-validates it against an active `Profile` row on every single request — + +```python +profile = Profile.objects.select_related("org").get( + user_id=user_id, org_id=org_id, is_active=True +) +``` + +— so a token minted while a user was a member of an org keeps working only as long as that +membership stays active. If the membership is revoked after the token was issued, this lookup +raises `Profile.DoesNotExist` and the middleware turns that into a 403 asking the user to log in +again, rather than trusting a claim that's gone stale. `role` in the token is a display +convenience, not the source of truth for authorization — every permission check in +[Permissions and roles](permissions-and-roles.md) reads `request.profile.role` fresh from the +database-backed `Profile` the middleware just re-validated, not from the JWT payload. + +## Refresh and rotation + +The old documentation for this project asserted refresh-token rotation and server-side tracking +without pointing at code. Here is what the code actually does. `POST /api/auth/refresh-token/` +(`OrgAwareTokenRefreshView`, `backend/common/views/auth_views.py`, wired at +`backend/common/urls.py:58-62`) is the only refresh endpoint — there's no separate route using +simplejwt's stock `TokenRefreshView`, so the `ROTATE_REFRESH_TOKENS`/`BLACKLIST_AFTER_ROTATION` +settings shown above describe the intent, but the actual rotation logic is hand-written in this +view rather than delegated to simplejwt's default serializer: + +```python +with transaction.atomic(): + token.blacklist() + new_token = OrgAwareRefreshToken.for_user_and_org(user, org, profile) +``` + +The presented refresh token is decoded and validated, its `org_id` claim (if any) is re-checked +against an active `Profile` exactly as in [Claims](#claims) above — a revoked membership gets a 403 +here too, not a fresh token — and then, in one atomic transaction, the presented token is +blacklisted and a replacement is minted carrying the same org context. Both writes share a +transaction specifically so a failure can never leave the caller tokenless while the old token +remains usable. This is real rotation with real server-side state: `RefreshToken.blacklist()` +writes to simplejwt's own `OutstandingToken`/`BlacklistedToken` tables (from the +`token_blacklist` app), which is what a stolen refresh token being replayed after the legitimate +client has already refreshed would hit — the blacklisted token fails validation on reuse. Those +bookkeeping rows aren't kept forever; `flush_expired_refresh_tokens` +(`backend/common/tasks.py`) deletes `OutstandingToken` rows once their own `expires_at` has passed, +scheduled nightly — see [Background jobs](background-jobs.md). + +An expired or already-blacklisted refresh token fails with `TokenError`, which this view turns into +a 401. A refresh token for a `User` whose account has since been deactivated (`user.is_active`) +also fails, explicitly, with a 403. + +## Organization switching + +A signed-in user can belong to more than one org, and a single JWT only ever carries one `org_id`. +`POST /api/auth/switch-org/` (`OrgSwitchView`) is how a client moves to a different one: it +requires only `IsAuthenticated` (not `HasOrgContext`), because it has to work for a token that has +no org claim at all yet — the state a fresh Google OAuth login is in, since +`GoogleOAuthCallbackView`/`GoogleIdTokenView` both mint their initial token with `org=None` and +leave org selection to a separate step. + +The view re-validates membership the same way the refresh endpoint does — +`Profile.objects.get(user=request.user, org_id=org_id, is_active=True)` — and 403s +(with an audit-logged `permission_denied` entry) if the caller has no active profile in the +requested org. It then optionally retires the refresh token the client is switching away from: + +```python +if str(token.get("user_id")) != str(request.user.id): + return +token.blacklist() +``` + +That ownership check is not incidental — without it, `refresh` would let any authenticated caller +blacklist *any* refresh token they could observe, turning a convenience parameter into a remote +logout for someone else's session. Retiring the old token is optional by design (existing clients +don't all send it, and failing the switch over a missing optional field would be worse than letting +one token expire on its own 14-day schedule), but when it is sent and does belong to the caller, it +stops working immediately rather than staying valid against the previous org for the rest of its +life. The org switch itself — retiring the old token and minting the replacement — happens inside +one transaction for the same reason the refresh endpoint's does. + +## Personal access tokens + +Personal access tokens (`bcrm_pat_…`, `PersonalAccessToken` in `backend/common/models.py`) are +for agents and scripts — the MCP server integration in particular — that need to authenticate as a +specific user without going through the JWT/OAuth flow on every call. A token is generated once +(`PersonalAccessToken.generate(profile, name, ...)`), shown to the caller exactly once, and stored +only as a SHA-256 hash (`token_hash`); the plaintext is not recoverable from the database +afterward. + +Resolution happens twice per request, deliberately: `GetProfileAndOrg` resolves the PAT first (so +`request.org` — and, downstream, the RLS context — is set before `RequireOrgContext` runs), and +DRF's `PATAuthentication` class (`backend/common/pat_auth.py`) reuses that same resolved token +(stashed as `request._pat`) rather than looking it up a second time. Both share one validation +function, `resolve_valid_pat`, which checks the token isn't revoked or expired +(`PersonalAccessToken.is_valid()`) and that both the owning profile and its org are still active — +so deactivating a profile cuts off its personal access tokens immediately, with no separate +revocation step. + +A token authenticates **as** its owning profile and inherits that profile's role and org in full. +`PersonalAccessToken` has a `scopes` field, but read the model's own comment before assuming it +restricts anything: + +```python +# NOTE: scopes are stored for forward-compatibility but are NOT enforced in +# Phase 1 — a token always inherits the owning profile's full role/permissions. +# Do not treat `scopes` as a trust boundary until enforcement lands. +``` + +Treat a personal access token as exactly as powerful as the user who created it — an admin's PAT +can do everything that admin can do through the UI. Self-service management lives at +`profile/tokens/` (a user manages only their own); admin oversight of every token in the org — +including a deactivated colleague's — is a deliberately separate, admin-only endpoint at +`org/tokens/`, so the self-service guard is never the thing that has to be loosened to support +oversight. See [Security hardening](../self-hosting/security-hardening.md#token-surfaces) for that +distinction in more depth. + +## Organization API keys + +Each `Org` has exactly one API key (`Org.api_key`, generated at creation), managed through +`GET`/`POST /api/org/api-key/` (`OrgApiKeyView`), admin-only in both directions. Unlike a personal +access token, this key doesn't authenticate as any particular person: `GetProfileAndOrg`'s +`_process_api_key_auth` resolves a request bearing this key (in a `Token` header) to the org's +**first active `ADMIN` profile** — + +```python +profile = Profile.objects.filter( + org=organization, role="ADMIN", is_active=True +).first() +``` + +— which makes a leaked org API key equivalent to a leaked admin session for that org, not a leaked +individual user session. There's no way to scope it down to less than full admin, so it's the +highest-privilege of the three credential types this page describes. Rotating it +(`POST /api/org/api-key/`) invalidates the previous key immediately — the response is explicit +that the old key is no longer valid. It's excluded from every nested API representation +(`OrganizationSerializer` never embeds it) and only ever served by this one endpoint. + +One piece of related configuration to be aware of: `GOOGLE_REDIRECT_URI` is defined in +`backend/crm/settings.py:360` but is dead — grep the backend and it's the only place that name +appears. The actual redirect URI used in the Google OAuth code exchange +(`GoogleOAuthCallbackView.post`) comes from the client's request body on each call +(`request.data.get("redirect_uri")`), not from this setting. Don't spend time configuring +`GOOGLE_REDIRECT_URI` expecting it to do anything; see +[Google OAuth](../self-hosting/google-oauth.md) for the setting that actually matters. diff --git a/docs/architecture/background-jobs.md b/docs/architecture/background-jobs.md new file mode 100644 index 00000000..35120a69 --- /dev/null +++ b/docs/architecture/background-jobs.md @@ -0,0 +1,119 @@ +# Background jobs + +This page is for anyone writing a new Celery task. If you're operating a deployment rather than +writing code, see [Email and Celery](../self-hosting/email-and-celery.md#rls-in-background-tasks) +instead — it covers running the worker and beat processes and has the same rule below from the +operator's side. This page exists because the rule is easy to get right by copying an existing +task and easy to get wrong by writing one from scratch. + +## Topology + +`backend/crm/celery.py` defines the app: `Celery("crm")`, configured from Django settings under the +`CELERY_` namespace, so `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` +(`backend/crm/settings.py`, both defaulting to `redis://localhost:6379/0`) are what it actually +connects with — there's no separate Celery-only config file. `app.autodiscover_tasks()` finds +`tasks.py` in each app; a second call, `app.autodiscover_tasks(related_name="celery_tasks")`, +exists specifically because the `tasks` Django app's own task module is named `celery_tasks.py` +to avoid colliding with the app's own name. + +Two processes run against this one app definition, and neither is required for the API itself to +answer requests: + +- **`celery -A crm worker`** executes tasks queued by the API — welcome emails, magic-link + delivery, assignment notifications, and more. +- **`celery -A crm beat`** fires the schedule in `app.conf.beat_schedule` (ten entries — recurring + invoice generation, overdue-invoice checks, payment reminders, expired-estimate checks, + stale-opportunity and goal-milestone scans, case SLA-breach scanning every five minutes, + stale-timer cleanup, and nightly cleanup of read notifications and expired refresh-token + records). Nothing runs any of these unless `beat` is also running. + +See [Email and Celery](../self-hosting/email-and-celery.md#running-a-worker) for the actual run +commands and what each scheduled entry does operationally. + +## Writing a task + +Celery workers never go through Django's HTTP middleware stack. `GetProfileAndOrg` and +`RequireOrgContext` — the two middleware classes that establish tenancy for a normal request, see +[Overview](overview.md#request-lifecycle) — simply never run for a task, so nothing sets the +PostgreSQL session variable `app.current_org` on the worker's connection for you. A fresh worker +connection has an empty context, and per RLS's fail-safe design (see +[Multi-tenancy and RLS](multi-tenancy-and-rls.md#policies-and-org_scoped_tables)) an empty context +makes every org-scoped query on a correctly-configured, non-superuser database role return zero +rows — not another org's rows, but not the rows you wanted either. + +The fix is one line, and it has to be the first thing a task that touches an org-scoped table does. +`set_rls_context(org_id)`, defined in `backend/common/tasks.py`, sets the same session variable the +middleware would have: + +```python +# backend/contacts/tasks.py +from celery import shared_task + +from common.tasks import set_rls_context +from contacts.models import Contact + + +@shared_task +def send_email_to_assigned_user(recipients, contact_id, org_id): + """Send Mail To Users When they are assigned to a contact""" + set_rls_context(org_id) + contact = Contact.objects.get(id=contact_id) + ... +``` + +Any task queued from a view already has `org_id` available (`request.profile.org.id`) to pass along +as an argument — pass it, and call `set_rls_context` with it before the first query, every time. + +A task that has to sweep every org rather than act on one record calls `set_rls_context` once per +org, inside the loop, immediately before that org's queries — for example +`cases.tasks.scan_for_breached_cases`: + +```python +for org in Org.objects.filter(id__in=list(org_ids)): + set_rls_context(org.id) + try: + total += _scan_org(org) + except Exception: + logger.exception("Escalation scan failed for org=%s", org.id) +``` + +Two kinds of task legitimately skip this call. First, a task whose table isn't org-scoped at all — +`flush_expired_refresh_tokens` operates on simplejwt's `OutstandingToken` table, which carries no +`org_id` column and isn't in `ORG_SCOPED_TABLES`, so there's no per-org context to set in the first +place. Second, a task that already sets context per-org inside a loop, as above, doesn't need it +again outside the loop. What doesn't qualify as an exception is "the query looks like it filters by +something else" — see below. + +## The RLS rule + +Stated plainly, because it's the one rule this page exists to make impossible to miss: **any Celery +task that reads or writes an org-scoped table must call `set_rls_context(org_id)` before its first +query against that table.** Nothing else establishes tenancy for a worker process. + +This codebase has a live example of what happens when a task skips it. +`purge_read_notifications` (`backend/common/tasks.py:381-397`) deletes read notifications older +than a cutoff: + +```python +@shared_task +def purge_read_notifications(days=NOTIFICATION_PURGE_DAYS): + """... Runs once across all orgs — RLS does not need a per-org context + here because the query targets `read_at`, which is intrinsic to the row, + not org-scoped logic.""" + cutoff = timezone.now() - timedelta(days=days) + deleted, _ = Notification.objects.filter( + read_at__isnull=False, read_at__lt=cutoff + ).delete() + ... +``` + +The docstring's reasoning doesn't hold: whether a *query condition* is org-specific has nothing to +do with whether the *table* is org-scoped, and `notification` is — it's in `ORG_SCOPED_TABLES` +(`backend/common/rls/__init__.py`) and carries the same `FORCE ROW LEVEL SECURITY` policy as every +other table on that list. This task never calls `set_rls_context`, so on the non-superuser database +role this project requires, it runs with an empty `app.current_org`, and its `DELETE` matches zero +rows across every org, silently, on every scheduled run. It doesn't fail loudly and it doesn't +purge the wrong org's data — it just never purges anything, which is its own quietly bad outcome: +the table grows unbounded while the schedule entry that's supposed to prevent that keeps reporting +success. Read the code a task actually executes, not the comment sitting above it, before trusting +either one. diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md new file mode 100644 index 00000000..864c1b6c --- /dev/null +++ b/docs/architecture/data-model.md @@ -0,0 +1,129 @@ +# Data model + +This page describes the shape every model in this codebase follows, the handful of ways core CRM +entities connect to each other, and a few conventions that are easy to assume rather than verify — +which is exactly why they're written down here with the file and line that makes each one true. + +## Core entities + +- **Lead** (`backend/leads/models.py`) — a prospect not yet qualified into a customer. + `LeadPipeline`/`LeadStage` give each org its own configurable Kanban columns. +- **Account** (`backend/accounts/models.py`) — a company or customer. `AccountEmail`/ + `AccountEmailLog` support account-linked inbound mailboxes. +- **Contact** (`backend/contacts/models.py`) — a person, optionally linked to an `Account`. +- **Opportunity** (`backend/opportunity/models.py`) — a sales deal in progress. + `OpportunityLineItem` holds quoted products; `StageAgingConfig` is per-org "how many days is too + long in this stage" configuration; `SalesGoal` is a quota/target tracked against `Opportunity` + data. +- **Case** (`backend/cases/models.py`) — a support ticket. By far the largest cluster of supporting + models: `CaseWatcher`, `CsatSurvey`, `Solution` (knowledge base), `CasePipeline`/`CaseStage`, + `ReopenPolicy`, `EscalationPolicy`, `InboundMailbox`/`EmailMessage` (email-to-ticket), + `RoutingRule`/`RoutingRuleState`, and `TimeEntry`. + `Case.account` is a direct foreign key (not nullable through an intermediate table); Cases carry + their own SLA fields (`sla_first_response_hours`, `sla_resolution_hours`, + `first_response_at`, `resolved_at`) directly on the model. +- **Task** (`backend/tasks/models.py`) — a to-do item, with its own configurable + `TaskPipeline`/`TaskStage`, and a separate `Board`/`BoardColumn`/`BoardTask`/`BoardMember` family + for freeform Kanban boards distinct from the Task pipeline. +- **Invoice** (`backend/invoices/models.py`) — billing, alongside `Estimate`, `RecurringInvoice`, + `Product`, `Payment`, and `InvoiceTemplate` in the same app. This app also owns the anonymous + client-portal views (see [Multi-tenancy and RLS](multi-tenancy-and-rls.md#portal-tokens)). +- **Order** (`backend/orders/models.py`) — `Order`/`OrderLineItem`. + +## Relationships + +The lead-to-customer path is a one-time conversion, not a standing foreign key. +`leads.services.convert_lead_to_account(lead_obj, request, create_opportunity=True)` +(`backend/leads/services.py`) runs as a side effect when a lead's `status` is updated to +`"converted"` through the normal lead update views (`backend/leads/views/lead_views.py`) — there is +no dedicated "convert" endpoint. It: + +1. `get_or_create`s an `Account` (case-insensitive name match within the org). +2. `get_or_create`s a `Contact` from the lead's email, if the lead has one, and links it to the + account both by direct FK and via `Account.contacts`. +3. Creates an `Opportunity` if requested and the lead carries enough data to justify one, copying + `assigned_to`/`teams`/`tags` across. +4. Reassigns the lead's `Comment`/`Attachments` rows (generic-relation `ContentType`/`object_id` + pairs) from the lead onto the new account. +5. Sets `lead_obj.status = "converted"`. + +There is deliberately no persisted link back from the created `Account`/`Contact`/`Opportunity` to +the originating `Lead` — `Lead`'s own model file documents this: earlier `converted_account`/ +`converted_contact`/`converted_opportunity`/`conversion_date` fields were removed because they were +"never populated, conversion just sets status." If you need to trace a record back to the lead it +came from, there's nothing in the schema to follow; only the account/contact name or email is +likely to match. + +`Contact` and `Account` are linked two separate ways at once: `Contact.account` is a nullable +foreign key (`on_delete=SET_NULL`), and `Account.contacts` is an independent many-to-many. Lead +conversion populates both. Code that needs "the contacts belonging to this account" should decide +deliberately which of the two it means, rather than assuming they're always in sync. + +`Opportunity.account` is a nullable FK to `Account`; `Opportunity.contacts` is a many-to-many to +`Contact`. `Case.account` is a required-in-practice (blank/null allowed, but `on_delete=CASCADE`) +FK to `Account`, and `Case.contacts` is a many-to-many to `Contact`. + +## Conventions + +Nearly every model in this codebase inherits `BaseModel` (`backend/common/base.py`), which via +`AuditModel` (`backend/common/mixins.py`) provides: + +- `id` — a `UUIDField` primary key (`default=uuid.uuid4`), not an auto-incrementing integer. +- `created_at`/`updated_at` — set automatically (`auto_now_add`/`auto_now`). +- `created_by`/`updated_by` — foreign keys to **`User`**, not `Profile`, set from + `crum.get_current_user()` in `BaseModel.save()`. This is worth being precise about: a permission + check that compares `request.profile` directly to `some_object.created_by` is comparing a + `Profile` instance to a `User` foreign key and will always be `False` — see + [Permissions and roles](permissions-and-roles.md#object-level-checks) for the bug class that + causes and how to compare them correctly (`profile.user_id == obj.created_by_id`). + +Three models opt out of this entirely and inherit plain `models.Model` instead: `User` +(`backend/common/models.py:34`), `MagicLinkToken` (`:524`), and `PortalAccessToken` (`:910`). All +three still declare their own `id = models.UUIDField(default=uuid.uuid4, primary_key=True)` by +hand, so the UUID-primary-key convention holds even for them — but none of the three has +`created_at`/`updated_by`/`created_by` fields, which makes sense for what they are: `User` predates +and sits outside the org-membership model entirely (`Profile` is what's org-scoped), and +`MagicLinkToken`/`PortalAccessToken` are short-lived, auth-bootstrap rows with no meaningful +"who last touched this" to record. + +Org-scoped models add one more field: an `org` foreign key to `common.Org`, either inherited from +`BaseOrgModel` or (the more common pattern in this codebase) declared by hand on top of +`BaseModel`. See [Multi-tenancy and RLS](multi-tenancy-and-rls.md#baseorgmodel) for which models do +which and why both are valid as long as the table is also registered and migrated for RLS. + +**`Opportunity.stage` is a fixed enum; `Lead`, `Case`, and `Task` stages are configurable +per-org pipelines — these are not the same kind of field even though they sound similar.** +`Opportunity.stage` is a plain `CharField` over `common.utils.STAGES` — `PROSPECTING`, +`QUALIFICATION`, `PROPOSAL`, `NEGOTIATION`, `CLOSED_WON`, `CLOSED_LOST` — the same six stages for +every organization, with no model backing a per-org column set. `Lead.stage`, `Case.stage`, and +`Task.stage`, by contrast, are nullable foreign keys to a per-org `LeadStage`/`CaseStage`/ +`TaskStage` row (itself belonging to a `LeadPipeline`/`CasePipeline`/`TaskPipeline`), so each org +can define its own Kanban columns for leads, cases, and tasks. All three of those models also keep +a separate, fixed-choices `status` field (`Lead.status`, `Case.status`, `Task.status`) alongside the +configurable `stage` FK — a stage's `maps_to_status` field is what keeps the two in sync when a +record moves between stages. A deal in `Opportunity` has no equivalent second field, because it has +no configurable stage to keep in sync with anything. + +Several models — `Lead`, `Opportunity`, `Case` among them — carry a `custom_fields = JSONField()` +whose values are validated against `common.CustomFieldDefinition`, a per-org schema extension +mechanism that doesn't require a migration to add a field for one org. + +Finally, one validation gotcha worth knowing before you rely on `Model.clean()`: DRF's +`ModelSerializer` calls `instance.save()`, not `instance.full_clean()`, so a model's `clean()` +method is invoked through the API only if that model's own `save()` explicitly calls +`self.full_clean()` itself — most don't. `Lead.clean()`, `Opportunity.clean()`, and `Case.clean()` +(`backend/cases/models.py:180`) all define real validation rules this way, and by default those +rules are silently unenforced through the API, because none of `Lead.save()`, `Opportunity.save()`, +or `Case.save()` calls `full_clean()`. A handful of other models do call it from their own +`save()`, so their `clean()` rules reach the API as a Django +`ValidationError` — a 500 unless the view happens to catch it, not a clean DRF 400: `Task.save()` +(`backend/tasks/models.py:403`), and `Comment.save()`/`Attachments.save()` +(`backend/common/models.py:316-318` and `:430-432`, both there specifically to enforce that a +comment or attachment's `org` matches the org of whatever record it's attached to). There's no +blanket rule for which models do this — check the individual `save()` method rather than assuming. +Where an unenforced `clean()` rule turned out to matter through the API, it's been reimplemented +directly in the serializer instead of relying on `full_clean()` — `opportunity/serializer.py`'s +`validate()` duplicates the two rules from `Opportunity.clean()` for exactly this reason, per its +own comment. If you add a `clean()` method to a model, verify whether that model's `save()` calls +`full_clean()` before assuming the rule is enforced anywhere outside a unit test that calls +`full_clean()` directly. diff --git a/docs/architecture/multi-tenancy-and-rls.md b/docs/architecture/multi-tenancy-and-rls.md new file mode 100644 index 00000000..133cc925 --- /dev/null +++ b/docs/architecture/multi-tenancy-and-rls.md @@ -0,0 +1,211 @@ +# Multi-tenancy and RLS + +This page is for anyone about to add an org-scoped model, or trying to understand why one query +returned no rows when it should have returned some. It explains the tenancy model from the +application-code side; [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md) covers the +same mechanism from the operator's side — creating the database role, running `manage_rls`, and +proving isolation holds against real data. Read that page too before you deploy anything; this one +is about what to do when you're writing a new model. + +## The model + +Every tenant's data lives in the same database, the same tables, distinguished by an `org_id` +column. Isolation is enforced twice, independently: + +1. **In application code**, every queryset that touches an org-scoped table filters explicitly — + `Model.objects.filter(org=request.profile.org)` — and every `create()`/`serializer.save()` + passes `org=request.profile.org` rather than trusting a client-supplied value. +2. **In PostgreSQL**, via Row-Level Security. Each org-scoped table carries a policy that compares + its `org_id` column to the session variable `app.current_org` + (`backend/common/rls/__init__.py`), which `RequireOrgContext` sets from the caller's validated + JWT on every request — see [Overview](overview.md#request-lifecycle) for exactly when. + +Neither layer is optional, and neither is a superset of the other in practice: see +[The two-layer contract](#the-two-layer-contract) below for why both have to hold. + +## BaseOrgModel + +`BaseOrgModel` (`backend/common/base.py`) is a `BaseModel` subclass that adds an `org` foreign key +to `common.Org`, an `OrgScopedManager` (giving you `Model.objects.for_org(org)` and +`Model.objects.for_request(request)` helpers), and a `["org", "-created_at"]` index. Its own +docstring states the intent plainly: "All tenant-owned models should inherit from this instead of +`BaseModel`." + +That said, verify this against the actual model files before assuming every org-scoped model +follows it, because most don't. Searching the codebase for classes that actually inherit +`BaseOrgModel` turns up four: `Order` and `OrderLineItem` (`backend/orders/models.py`), and +`PersonalAccessToken` and `PackApplication` (`backend/common/models.py`). Every core CRM model — +`Lead`, `Account`, `Contact`, `Opportunity`, `Case`, `Task` — and most of the supporting tables +around them (`CaseWatcher`, `CsatSurvey`, `Macro`, `BusinessCalendar`, and others) instead inherit +plain `BaseModel` and declare their own `org = models.ForeignKey(Org, on_delete=models.CASCADE, +related_name="...")` field by hand, shaped identically to what `BaseOrgModel` would have given +them. This isn't drift or oversight: several of these models say so directly in their own +docstrings, citing a coordination decision to prefer a hand-declared FK over `BaseOrgModel`. For +example, `backend/cases/models.py` on `CaseWatcher`: + +```python +class CaseWatcher(BaseModel): + """A profile subscribed to updates on a case. + + Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and + declare our own org FK rather than using BaseOrgModel — RLS still + applies via the migration that adds the `case_watcher` table. + """ +``` + +(The `docs/cases/COORDINATION_DECISIONS.md` file that comment and two others like it — in +`backend/business_hours/models.py` and `backend/macros/models.py` — cite does not exist in this +checkout; treat the reasoning captured in the code comments themselves as the source of truth.) + +The practical takeaway: inheriting `BaseOrgModel` is a correct and recommended way to add an `org` +field to a new model — doing so also gets you the `OrgScopedManager` helpers +(`Model.objects.for_org(org)`, `Model.objects.for_request(request)`) for free, which is itself a +reason to prefer it — and you should default to it unless you have a specific reason to match this +codebase's more common hand-rolled-FK convention instead (for consistency with a sibling model in +the same app, for instance). But inheriting it is not what makes RLS protection work, +and it is not sufficient on its own — `PersonalAccessToken` inherits `BaseOrgModel` and is +*deliberately excluded* from RLS (see [Policies and ORG_SCOPED_TABLES](#policies-and-org_scoped_tables) +below). What actually determines whether a table is protected is the next section. + +## Policies and ORG_SCOPED_TABLES + +`ORG_SCOPED_TABLES` in `backend/common/rls/__init__.py` is the single list that determines which +tables get RLS policies — at the time of writing it has 61 entries. `get_enable_policy_sql(table)` +generates the SQL that protects each one: + +```sql +ALTER TABLE "" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "
" FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS org_isolation ON "
"; +DROP POLICY IF EXISTS org_insert_check ON "
"; + +CREATE POLICY org_isolation ON "
" + FOR ALL + USING (org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))); + +CREATE POLICY org_insert_check ON "
" + FOR INSERT + WITH CHECK (org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))); +``` + +`FORCE ROW LEVEL SECURITY` matters because without it the table owner — typically the role that ran +migrations — would be exempt from its own table's policies. `NULLIF` turns an unset (empty-string) +session variable into SQL `NULL`, and `org_id::text = NULL` is never true, so an empty context +yields zero rows rather than every org's rows: fail-safe by construction, not by convention. + +Adding RLS to a new org-scoped model is exactly three steps, and skipping any one of them leaves +the table looking protected while it isn't: + +1. **Give the model an `org` field.** Inherit `BaseOrgModel`, or — following this codebase's + dominant pattern, see [BaseOrgModel](#baseorgmodel) above — declare `org = + models.ForeignKey(Org, on_delete=models.CASCADE, related_name="...")` directly. +2. **Add the table's `db_table` name to `ORG_SCOPED_TABLES`** in `backend/common/rls/__init__.py`. +3. **Write a migration** that calls `get_enable_policy_sql('your_table')` inside a `RunPython` + operation, guarded so it's a no-op on SQLite (the test database has no concept of row security): + +Here is a real one, `backend/opportunity/migrations/0012_enable_rls_sales_goal.py`, in full: + +```python +from django.db import connection, migrations + +from common.rls import get_disable_policy_sql, get_enable_policy_sql + +TABLE = "sales_goal" + + +def enable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_enable_policy_sql(TABLE)) + + +def disable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_disable_policy_sql(TABLE)) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("opportunity", "0011_opportunity_kanban_order"), + ] + + operations = [ + migrations.RunPython(enable_rls, disable_rls), + ] +``` + +`get_enable_policy_sql` opens with the two `ALTER TABLE` statements, then — before either +`CREATE POLICY` — drops both policies with `DROP POLICY IF EXISTS`, so re-running this kind of +migration is idempotent — useful if a table's policy needs to be recreated after a schema change. + +Skip step 2 and the consequence is worse than "no protection": `manage_rls --status` only reports +on tables that are actually in `ORG_SCOPED_TABLES`, so an unregistered table doesn't show up as +"disabled" — it doesn't show up at all, and the gap is invisible to the one tool that's supposed to +catch it. The one deliberate exception in this codebase is `personal_access_token`: it inherits +`BaseOrgModel` (so it has an `org` field) but is intentionally left out of `ORG_SCOPED_TABLES`, +because it's looked up by `token_hash` before any tenant context can exist — an "auth-bootstrap" +table, in the same category as `Org` itself. Its isolation is enforced instead by explicit +`org`/`profile` filters in `backend/common/views/pat_views.py`. That exception is the proof that +inheriting `BaseOrgModel` and being RLS-protected are two separate facts, not one. + +## The two-layer contract + +RLS is a safety net, not a replacement for the ORM filter. Every view and serializer still writes +`Model.objects.filter(org=request.profile.org)` and passes `org=request.profile.org` explicitly on +create — that ORM filter is the contract; RLS is what catches the case where a filter is missing, +forgotten, or wrong. The two layers fail differently, which is exactly why both are required: + +- If the ORM filter is missing but RLS is correctly configured (a non-superuser database role, see + [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#why-rls)), the query still returns + only the caller's org's rows — RLS silently saved you. +- If the database role is a superuser — which is the default (`DBUSER` unset falls back to + `postgres`) unless you deliberately change it — RLS is bypassed entirely, silently, with no error + and no log line. In that configuration, the ORM filter is the *only* thing standing between a + request and another org's data. A development environment where the DB role happens to be a + superuser will look identical whether the ORM filter is present or not; only + `manage_rls --verify-user` against a real, correctly-configured role tells you which one you're + actually running. + +Treat every new queryset the same way regardless of which layer you're thinking about: write the +`org=` filter as if RLS didn't exist, because on a misconfigured deployment, it doesn't. + +## Portal tokens + +The client portal — invoice and estimate links emailed to customers, and CSAT survey links — is the +one place in this codebase that has to read org-scoped rows with no authenticated user and no JWT +at all. That's a genuine chicken-and-egg problem for RLS: you cannot set `app.current_org` until +you know the org, and with RLS enforcing isolation, you cannot read the row that would tell you the +org until the context is already set. + +`common.models.PortalAccessToken` (`backend/common/models.py`, defined starting at line 910) breaks +the cycle. It's a small, deliberately **unscoped** table — absent from `ORG_SCOPED_TABLES`, no RLS +policy — that maps `sha256(url_token)` to an `org_id`, populated at the same moment each portal +token is minted (`common/portal_tokens.py:register_portal_token`). A public view hashes the token +it was handed in the URL, looks up the org in this unscoped table, and only then sets the RLS +context and queries the actual resource: + +```python +# backend/invoices/public_views.py +def _resolve_org_context(token, resource_type): + org_id = resolve_portal_org(token, resource_type) + if org_id: + set_rls_context(org_id) + return org_id +``` + +An unknown or malformed token leaves the RLS context empty; the subsequent scoped query then +returns nothing under RLS's fail-safe default, and the caller gets the same 404 a disabled link +would produce — a stranger probing token values learns nothing about whether one is real. This is +the same "resolve org first via an unscoped, narrow lookup, then call `set_rls_context`" pattern +Celery tasks use (see [Background jobs](background-jobs.md)); it adds a dedicated, narrow surface +rather than weakening the isolation policy on `invoice`, `estimate`, or `csat_survey` themselves. +`backend/docs/PORTAL_RLS.md` documents the reasoning and the alternatives that were deliberately +rejected — notably, granting the anonymous path `BYPASSRLS` — but its "what is still open" section +describes the state *before* `PortalAccessToken` existed; the resolution above is what actually +runs today. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 00000000..31b9c234 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,120 @@ +# Architecture overview + +This section is for developers extending or auditing BottleCRM, not for operators deploying it — +see Self-hosting for that. It explains how the backend is put together: what runs before a view +ever sees a request, what each Django app owns, and where to go for the detail on tenancy, +authentication, permissions, the data model, and background jobs. + +## The three clients + +A SvelteKit web app (`frontend/`), a Flutter mobile app (`mobile/`), and any third-party +integration all talk to the same Django REST Framework backend (`backend/`) over the same JSON +API — there is no separate mobile API or web-only endpoint set. `crm/urls.py` mounts the entire +API under `/api/`, split into `common.app_urls` (auth, org/profile management, teams, tags, +notifications, documents, custom fields, and one route each into `accounts`, `contacts`, `leads`, +`opportunity`, `tasks`, `cases`, `invoices`, boards, business hours, and macros) plus +`invoices.public_urls` for the anonymous client-portal endpoints. Because every client shares one +API, an authorization rule enforced in a view protects all three clients at once — and, +conversely, a rule enforced only in the SvelteKit UI protects none of them, since a script or a +modified mobile build reaches the API directly. + +## Request lifecycle + +Tenancy is established in middleware, before any view code runs, in a fixed order defined in +`MIDDLEWARE` in `backend/crm/settings.py`: + +```python +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", + "crum.CurrentRequestUserMiddleware", + "common.middleware.get_company.GetProfileAndOrg", + "common.middleware.rls_context.RequireOrgContext", +] +``` + +The two that matter for multi-tenancy run last, in this order: + +1. **`GetProfileAndOrg`** (`backend/common/middleware/get_company.py`) resolves who the caller is + and which org they're acting in. For a request bearing a personal access token it resolves the + token first — deliberately before the JWT branch, so a PAT is never handed to the JWT decoder. + For a JWT-bearing request it decodes the access token, reads the `org_id` claim out of the + signed payload (never out of a header or body — see + [Authentication](authentication.md#claims)), and looks up an active `Profile` for that + `user_id`/`org_id` pair; only if that profile exists does it set `request.profile` and + `request.org`. For an org-API-key request it resolves the key to the org's first active + `ADMIN` profile. In every path, the org on the request is a fact the server derived and + verified — never a value taken as-is from the client. +2. **`RequireOrgContext`** (`backend/common/middleware/rls_context.py`) runs next and does two + things: it rejects any request to a non-exempt path with a 403 if `request.org` was never set + by the step above, and — this is the step that actually enables tenant isolation — it sets the + PostgreSQL session variable `app.current_org` to that org's id with + `SELECT set_config('app.current_org', %s, false)`, and resets it to an empty string after the + response. Every Row-Level Security policy in the schema keys off this one session variable; see + [Multi-tenancy and RLS](multi-tenancy-and-rls.md) for how. + +So the order is: identity and org are established and validated from the JWT first, and only once +that has succeeded is the database told which org's rows the rest of this request may see. A view +never runs before both steps have completed — `RequireOrgContext` sits after `GetProfileAndOrg` in +the list above and returns a response itself (the 403) rather than calling `get_response` when the +context is missing, so a request that fails tenancy resolution never reaches URL dispatch. + +This file also defines an older class, `SetOrgContext`, whose own docstring describes it as the +one wired into `MIDDLEWARE` — that docstring is stale. `SetOrgContext` sets the session variable +but never rejects a context-less request; `RequireOrgContext` is what `settings.py` actually +lists, and it is the one enforced. + +One layer above middleware, DRF's own authentication runs at view dispatch time +(`DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK`: `PATAuthentication`, then simplejwt's +`JWTAuthentication`, then `APIKeyAuthentication`) to populate `request.user` for `IsAuthenticated` +and similar permission classes. This is a second, independent pass over the same credential — +`PATAuthentication` reuses the PAT the middleware already resolved (stashed as `request._pat`) to +avoid a second lookup, but a JWT is decoded once by the middleware for org context and again by +`JWTAuthentication` for `request.user`. Every view still needs `permission_classes` set correctly; +authentication alone only establishes identity, not what that identity may do — see +[Permissions and roles](permissions-and-roles.md). + +## Backend layout + +``` +backend/ +├── crm/ # Project config: settings.py, urls.py, celery.py +├── common/ # Cross-cutting: User/Org/Profile, middleware, RLS config, permissions, +│ # auth views, personal access tokens, notifications, tags, teams, custom +│ # fields, documents, packs, security audit log +├── accounts/ # Account (company/customer) records +├── contacts/ # Contact (person) records +├── leads/ # Leads, lead pipelines/stages, lead-to-account conversion +├── opportunity/ # Sales pipeline: Opportunity, line items, stage aging, sales goals +├── cases/ # Support tickets: Case, SLAs, CSAT, escalation/routing, inbound email +├── tasks/ # Task management and Kanban boards (Board/BoardColumn/BoardTask) +├── invoices/ # Billing: invoices, estimates, recurring invoices, products, payments, +│ # and the anonymous client-portal views (invoices.public_urls) +├── orders/ # Sales orders and order line items +├── business_hours/ # Per-org working-hours calendars that SLA timers honor +└── macros/ # Canned responses / saved replies +``` + +`common` is the one app every other app depends on for tenancy itself: it owns the +`User`/`Org`/`Profile` models, the RLS configuration (`common/rls/`), the permission classes +(`common/permissions.py`), and every authentication path (`common/views/auth_views.py`, +`common/pat_auth.py`, `common/external_auth.py`). Every domain app's *models* build on `common` +(`BaseModel`/`BaseOrgModel` from `common.base`, `Org`/`Profile` from `common.models`) and never the +other way around. That one-directional rule doesn't extend to `common`'s own *views*, though: +several of them are cross-cutting by nature and import domain-app models directly — the dashboard +(`common/views/dashboard_views.py`), global search (`common/search_views.py`), tag usage counts +(`common/views/tags_views.py`), and vertical-pack application (`common/packs/applier.py`) all pull +in `Lead`, `Opportunity`, `Case`, and others, because aggregating across every domain app is +exactly what those views are for. Only the model layer is strictly one-directional. + +The `teams` Django app referenced in some older documentation and comments no longer exists as a +separate app; `Teams` is a model in `common/models.py`, and `INSTALLED_APPS` in +`backend/crm/settings.py` has a commented-out `# "teams", # Merged into common app` line marking +where it used to be. diff --git a/docs/architecture/permissions-and-roles.md b/docs/architecture/permissions-and-roles.md new file mode 100644 index 00000000..fc89ff5e --- /dev/null +++ b/docs/architecture/permissions-and-roles.md @@ -0,0 +1,188 @@ +# Permissions and roles + +Authentication (covered in [Authentication](authentication.md)) establishes who is making a +request. This page is about the separate question every view still has to answer for itself: given +that identity, is this specific action on this specific object allowed? DRF's permission classes +answer the first, coarse half of that; object-level checks inside the view answer the rest. + +## Roles + +`Profile.role` (`backend/common/models.py`) is a two-value `CharField`: +`common.utils.ROLES = (("ADMIN", "ADMIN"), ("USER", "USER"))`, defaulting to `USER`. That's the +role a person holds *within one org* — a user with profiles in two orgs can be `ADMIN` in one and +`USER` in the other, because a role lives on the `Profile` (the user-org membership row), not on +`User` itself. + +A second, independent flag exists alongside it: `Profile.is_organization_admin`, a plain boolean, +exposed as the `.is_admin` property. It is genuinely a separate field from `role` — nothing +enforces that the two agree — and a fair amount of permission-checking code treats either one as +sufficient for "this person is an admin": + +```python +# common/permissions.py — IsOrgAdmin +return request.profile.role == "ADMIN" or request.profile.is_organization_admin +``` + +```python +# cases/access.py +def is_org_admin(profile): + """True for the two ways this codebase spells "admin".""" + return profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False)) +``` + +When you write a new admin check, check both, the way these two do — a check that only tests +`role == "ADMIN"` will silently deny someone whose admin status was granted only through +`is_organization_admin`, and vice versa. + +Separately from any org role, `IsSuperAdmin` (`backend/common/permissions.py`) gates +platform-level access on `User.is_superuser` — an explicit flag set with `manage.py +createsuperuser`, the Django admin, or another audited path, never inferred from an email address +or domain. The class's own docstring explains why that matters: deriving super-admin status from +an email domain would hand platform-wide access — every org, every user — to anyone who can obtain +an account at that domain, turning an ordinary signup into vertical privilege escalation. This flag +is unrelated to any `Profile.role`; a `User` can be `is_superuser` with no org membership at all. + +## Permission classes + +`backend/common/permissions.py` defines the building blocks every view composes: + +- **`HasOrgContext`** — requires `request.profile` and `request.org` to both be set (by the + middleware described in [Overview](overview.md#request-lifecycle)) and `request.profile.is_active` + to be true. This is the check that turns "the JWT didn't carry a valid org claim" into a clean + 403 with the message "Organization context is required. Please login again." at the view layer, + mirroring what `RequireOrgContext` already enforces at the middleware layer. +- **`IsOrgAdmin`** — `role == "ADMIN"` or `is_organization_admin`, as shown above. +- **`IsSuperAdmin`** — `user.is_superuser`, as shown above. + +The standard combination on nearly every org-scoped view in this codebase is + +```python +permission_classes = (IsAuthenticated, HasOrgContext) +``` + +— for example `CaseListView`, `CaseAttachmentView`, and `CaseCommentView` in +`backend/cases/views.py`, or `TagsListView` in `backend/common/views/tags_views.py`. Notice what +that combination does *not* do: it doesn't check role, and it doesn't check which specific object +the request is about. `IsAuthenticated` + `HasOrgContext` gets you "a real, active member of this +org is making this request" and nothing more specific than that. Role checks +(`request.profile.role == "ADMIN"`) and object-level checks are layered on inside the view methods +themselves — DRF's declarative `permission_classes` list doesn't see the object being acted on +until the view has already fetched it, so per-object authorization has to live in code, which is +what the next section covers. + +## Object-level checks + +`backend/cases/access.py` is the clearest example in the codebase of object-level authorization +done deliberately, in one place, rather than re-implemented per view. Its own module docstring +explains the problem it fixes: before this module existed, "who may read/write/delete a case" was +answered independently in five different call sites, and had drifted into three different answers +by accident — a watcher could see their case in the list but got a 403 opening it, because the list +view's queryset included watchers and the detail view's permission check didn't. That's the +consequence of not centralizing this kind of check: verbs disagree with each other silently, and +the person who notices is a user hitting a wall the product didn't intend. + +The module now states three explicit, different rules and enforces them from one place: + +```python +def is_org_admin(profile): + return profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False)) + +def has_case_write_access(profile, case): + if is_org_admin(profile): + return True + if profile.user_id == case.created_by_id: + return True + return profile.id in {p.id for p in case.assigned_to.all()} + +def assert_case_delete_access(profile, case): + """Narrower than writing on purpose: an assignee is somebody the work was + handed to, which is a reason to let them work the ticket and not a reason + to let them erase it.""" + if is_org_admin(profile): + return + if profile.user_id == case.created_by_id: + return + raise PermissionDenied(_DENIED) +``` + +Two things worth noticing in `has_case_write_access`: the comparison is `profile.user_id == +case.created_by_id` — a `User` id compared to a `User` id — not `profile == case.created_by`. +`Case.created_by` (from `common/mixins.py`'s `AuditModel`, which nearly every model inherits +transitively through `BaseModel` — see [Data model](data-model.md#conventions) for the few +exceptions) is a foreign key to `User`, not to `Profile`. Comparing a `Profile` instance directly to +a `User` foreign key is always `False` — a comparison that type-checks in Python but can never be +true, which quietly turns an intended "creator or admin" check into an "admin only" check with no +error anywhere. + +`backend/cases/views.py`'s own `CaseAttachmentView.delete` docstring documents two separate defects +having been live in that endpoint before the fix — an unscoped `objects.get(pk=pk)` lookup (no org +filter at all), and this same `created_by` type mismatch — and its closing line says only that "the +same one-line **lookup** bug is still open in `leads`, `tasks` and `opportunity`." That sentence is +about the missing-org-filter defect specifically, not the `created_by` comparison, and — checked +against the current source of all three apps rather than taken on the docstring's word — it's now +stale for one of the three: + +- **`leads`** (`leads/views/lead_interactions.py`) — the unscoped lookup is still open (`.objects + .get(pk=pk)` at line 204, no `org=`). The `created_by` comparison here is correct, though: + `request.profile.user == self.object.created_by` (line 208) compares a `User` to a `User`, not a + `Profile` to a `User`, so this endpoint's `created_by` check works. +- **`tasks`** (`tasks/views/task_views.py`) — both defects are fixed: the lookup is + org-filtered (`.filter(pk=pk, org=request.profile.org)`, lines 975-977) and the comparison is + `request.profile.user_id == self.object.created_by_id` (line 987), with a comment at lines 982-984 + explaining why. The cases docstring's claim that the lookup bug is "still open" in `tasks` is + stale. +- **`opportunity`** (`opportunity/views/opportunity_interactions.py` and `kanban_views.py`) — both + defects are open, and the type mismatch exists at two separate call sites: `request.profile == + self.object.created_by` in the attachment delete (`opportunity_interactions.py:158` for the + unscoped lookup, `:162` for the comparison) and again as `is_owner = request.profile == + opportunity.created_by` in the Kanban move endpoint (`kanban_views.py:162`). + +So as of this writing: the `created_by` type-mismatch bug itself is open in `opportunity` only (two +call sites); the separate unscoped-lookup bug is open in `leads` and `opportunity`. Neither defect +is something this documentation task fixes — it's flagged here, precisely, because a docstring that +lists which apps still have a bug goes stale exactly like any other comment does, and the fix in +this case is checking the current source yourself, the way this paragraph did, rather than trusting +either the docstring or a prior summary of it. When you write a `created_by` comparison, compare +`profile.user_id` to `obj.created_by_id`, and write a test that proves the check can return both +`True` and `False`. + +## Read wide, write narrow + +A pattern recurs across this codebase enough to name: for a given kind of resource, who may *read* +it is broader than who may *change* it, which in turn is broader than who may *destroy* it. Two +concrete shapes of it: + +**Per-object access**, as in `cases/access.py` above: + +``` +read admin · creator · assignee · watcher +write admin · creator · assignee +delete admin · creator +``` + +Watching a case is subscribing to updates, not being handed the keys to it — so the watcher clause +that widens *read* access is deliberately not carried into *write* or *delete*. Each verb states its +own rule; none is inferred from another. + +**Shared, org-wide configuration** — tags, vertical packs, and similar org-level settings — follows +a simpler but equally deliberate version of the same shape: any active member of the org may read +it, only an admin may change it. `TagsListView` (`backend/common/views/tags_views.py`) is a clean +example: `get` has no additional check beyond the standard `(IsAuthenticated, HasOrgContext)`, but +`post` gates explicitly: + +```python +def post(self, request, *args, **kwargs): + """Create a new tag (admin only).""" + if request.profile.role != "ADMIN" and not request.user.is_superuser: + return Response( + {"error": True, "errors": "Only admins can create tags"}, + status=status.HTTP_403_FORBIDDEN, + ) +``` + +`common/views/pack_views.py` states the same shape as a doc comment before a single line of code: +"any authenticated member may list packs; only an org ADMIN may apply one or clear sample data." +When you're adding a new endpoint over something that's genuinely shared, org-wide configuration +rather than a record with a specific owner, this is the default to reach for: don't gate the read +at all beyond org membership, and gate every write and destroy behind an explicit admin check in +the view — not a `permission_classes` entry that would also block the read. diff --git a/docs/contributing/adding-an-org-scoped-model.md b/docs/contributing/adding-an-org-scoped-model.md new file mode 100644 index 00000000..52bfa8b9 --- /dev/null +++ b/docs/contributing/adding-an-org-scoped-model.md @@ -0,0 +1,256 @@ +# Adding an org-scoped model + +This page walks through adding a new tenant-owned model end to end, using a worked example — a +`CallLog` model recording calls made against a `Lead` — so every code block is something you could +actually drop into the tree, not pseudocode. It assumes you've read +[Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md), which explains *why* each of +these steps exists; this page is the checklist for *doing* it. + +## The checklist + +1. **Inherit `BaseOrgModel`** (`common.base`) so the model gets an `org` foreign key, the UUID + primary key, audit fields, and the `OrgScopedManager` helpers for free. +2. **Add the table's `db_table` name to `ORG_SCOPED_TABLES`** in `backend/common/rls/__init__.py`. +3. **Write a migration** that calls `get_enable_policy_sql('your_table')` inside a `RunPython` + operation, with a `dependencies` entry that guarantees the table already exists when it runs. +4. **Filter every queryset** that touches the model by `org=request.profile.org`. +5. **Pass `org=...` explicitly** on every `create()` / `serializer.save()` — never trust a + client-supplied org. +6. **Write a test for both the allowed and the denied path** — an in-org request that succeeds, and + a cross-org request that doesn't see or can't write the row. + +Steps 2 and 3 are both required and neither substitutes for the other: registering the table +without a migration that actually calls `get_enable_policy_sql` leaves the table with no policies at +all, and a migration that calls `get_enable_policy_sql` without registering the table in +`ORG_SCOPED_TABLES` means `manage_rls --status` can never report on it, so a gap there is invisible +to the one tool meant to catch it. See +[Common mistakes](../self-hosting/postgresql-and-rls.md#common-mistakes) for both failure modes +described in more detail. + +## The model + +```python +# backend/leads/models.py +from django.db import models + +from common.base import BaseOrgModel + +class CallLog(BaseOrgModel): + """A record of an outbound or inbound call made against a lead.""" + + DIRECTION_CHOICES = ( + ("OUTBOUND", "Outbound"), + ("INBOUND", "Inbound"), + ) + + lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name="call_logs") + direction = models.CharField(max_length=10, choices=DIRECTION_CHOICES) + notes = models.TextField(blank=True, default="") + duration_seconds = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name = "Call Log" + verbose_name_plural = "Call Logs" + db_table = "call_log" + ordering = ("-created_at",) + indexes = [models.Index(fields=["org", "-created_at"])] +``` + +`BaseOrgModel` is abstract, and — this is easy to get wrong, and this codebase has a live example of +getting it wrong — a subclass's own `Meta` class does **not** automatically inherit the abstract +parent's `indexes` just because the model inherits `BaseOrgModel`. `Order` (`backend/orders/models.py`, +one of the few models here that actually inherits `BaseOrgModel` rather than hand-declaring its own +`org` foreign key) declares its own `Meta` with `db_table`, `verbose_name`, and `ordering`, but no +`indexes` — so `Order._meta.indexes` is `[]` at runtime, not the `["org", "-created_at"]` index +`BaseOrgModel.Meta` defines. `backend/orders/migrations/0003_remove_order_orders_org_id_9026a7_idx_and_more.py` +is the receipt: it drops that exact index from both `order` and `order_line_item`, tables that +otherwise get every other query filtered by `org`. `PersonalAccessToken` (`backend/common/models.py`) +is the codebase's real example of doing this correctly — its own `Meta` explicitly redeclares +`indexes = [models.Index(fields=["org", "-created_at"])]` rather than assuming it's inherited, which +is exactly what the `CallLog` example above copies. `BaseOrgModel` reliably gives you `id` (UUID +primary key), `org`, `created_at`, `updated_at`, `created_by`, and `updated_by` regardless of whether +your model declares its own `Meta` — those are fields and a `save()` override on the class itself, not +something `Meta` mediates. The `["org", "-created_at"]` index is the one thing on `BaseOrgModel` that +your own `Meta` can silently drop, so redeclare it (as above) any time your model needs its own +`Meta` for a `db_table`, `ordering`, or anything else. + +## Registering the table + +Add the table's `db_table` name — `call_log`, matching `Meta.db_table` above — to +`ORG_SCOPED_TABLES` in `backend/common/rls/__init__.py`. This list currently has 61 entries (see the +file itself for the current count — it changes as the project grows, so don't rely on a number +copied from documentation); a new entry looks like any other: + +```python +# backend/common/rls/__init__.py +ORG_SCOPED_TABLES = [ + ... + "task", + "invoice", + "call_log", # CallLog — call records against a lead + # Supporting entities + "comment", + ... +] +``` + +## The RLS migration + +Write a dedicated migration for the new table's policies, modeled on a real one already in the tree, +`backend/opportunity/migrations/0012_enable_rls_sales_goal.py`: + +```python +# backend/leads/migrations/0017_enable_rls_call_log.py +from django.db import connection, migrations + +from common.rls import get_disable_policy_sql, get_enable_policy_sql + +TABLE = "call_log" + + +def enable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_enable_policy_sql(TABLE)) + + +def disable_rls(apps, schema_editor): + if connection.vendor != "postgresql": + return + with connection.cursor() as cursor: + cursor.execute(get_disable_policy_sql(TABLE)) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("leads", "0016_call_log"), # the migration that actually creates call_log + ] + + operations = [ + migrations.RunPython(enable_rls, disable_rls), + ] +``` + +`atomic = False` matches every RLS migration in this codebase — `DROP`/`CREATE POLICY` each take an +`ACCESS EXCLUSIVE` lock, so committing per-statement instead of holding one long transaction keeps +each lock brief. `get_enable_policy_sql` itself opens with `DROP POLICY IF EXISTS` before creating +either policy, so the migration is idempotent if it's ever re-run. + +!!! warning "The dependency is not decorative — get it wrong and the migration silently does nothing" + `dependencies = [("leads", "0016_call_log")]` above is what guarantees Django applies this + migration *after* the one that runs `CreateModel` for `CallLog`. If that dependency were missing + or pointed at an earlier migration, Django would be free to apply this one first — and + `ALTER TABLE "call_log" ENABLE ROW LEVEL SECURITY` against a table that doesn't exist yet would + raise a hard error, not fail silently, *unless* the migration is written defensively with an + existence check first. `backend/common/migrations/0034_rls_pipeline_tables.py` is exactly that + defensive style — it checks `get_check_table_exists_sql()` before stamping each table, because + it registers six tables across three different apps in one migration and couldn't express a + single clean dependency chain for all of them. Its own comment explains the cost: get the + dependencies wrong there and the check silently prints `Skipping
(table does not exist)` + and moves on — the migration "succeeds," CI is green, and the table simply never gets a policy. + For a single new model in one app, prefer the plain `opportunity/0012` shape above (a hard + dependency, no existence check, a real error if the ordering is ever wrong) over copying + `0034`'s defensive pattern — a loud failure during development is much cheaper than a policy + that quietly never gets created. + +## The view + +Every view touching the new model needs both the standard permission classes and an explicit org +filter — RLS is the safety net, not the contract (see +[The two-layer contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract)): + +```python +# backend/leads/views/call_log_views.py +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.permissions import HasOrgContext +from leads.models import Lead, CallLog +from leads.serializer import CallLogSerializer + + +class CallLogListView(APIView): + permission_classes = (IsAuthenticated, HasOrgContext) + + def get(self, request, lead_id, format=None): + lead = get_object_or_404(Lead, id=lead_id, org=request.profile.org) + logs = CallLog.objects.filter(org=request.profile.org, lead=lead) + return Response(CallLogSerializer(logs, many=True).data) + + def post(self, request, lead_id, format=None): + lead = get_object_or_404(Lead, id=lead_id, org=request.profile.org) + serializer = CallLogSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(org=request.profile.org, lead=lead, created_by=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) +``` + +Two things to notice: the `Lead` lookup itself is org-filtered (`get_object_or_404(Lead, id=lead_id, +org=request.profile.org)`), so a cross-org caller gets a 404 on the parent object before ever +reaching the `CallLog` query — and `org` is passed to `serializer.save()` explicitly rather than +accepted from `request.data`, the same [server-owned field](security-rules.md#server-owned-fields) +rule that applies everywhere else. + +## The tests + +Write both directions, using the shared fixtures in `backend/conftest.py` (`org_a`, `org_b`, +`admin_client`, `org_b_client`, and the rest) — modeled on the real pair in +`opportunity/tests/test_sales_goals.py`. `org_a`, `org_b`, `admin_client`, and `org_b_client` are +real, shared fixtures already defined in `backend/conftest.py`; `lead_in_org_a` below is not — it's a +local, file-scoped fixture this example defines itself, the same way `leads/tests/ +test_lead_access_and_totals.py` has its own local `_lead()` helper rather than a shared one (there is +no org-scoped `Lead` fixture shared across the suite, because what fields a test needs on its `Lead` +varies too much to make one worth sharing): + +```python +import pytest + +from leads.models import CallLog, Lead + + +@pytest.fixture +def lead_in_org_a(org_a): + return Lead.objects.create( + first_name="Ada", + last_name="Lovelace", + email="ada@example.com", + status="in process", + company_name="Analytical Engines", + org=org_a, + ) + + +@pytest.mark.django_db +def test_admin_can_create_call_log(admin_client, org_a, lead_in_org_a): + response = admin_client.post( + f"/api/leads/{lead_in_org_a.id}/call-logs/", + {"direction": "OUTBOUND", "notes": "Left voicemail"}, + format="json", + ) + assert response.status_code == 201 + assert CallLog.objects.filter(lead=lead_in_org_a, org=org_a).exists() + + +@pytest.mark.django_db +def test_org_b_cannot_read_org_a_call_logs(org_b_client, org_a, lead_in_org_a): + """The denied path — mirrors test_org_isolation in test_sales_goals.py.""" + CallLog.objects.create(lead=lead_in_org_a, org=org_a, direction="OUTBOUND") + + response = org_b_client.get(f"/api/leads/{lead_in_org_a.id}/call-logs/") + + # The parent Lead lookup is org-scoped, so a foreign org gets a 404 on the + # lead itself, before the CallLog query ever runs. + assert response.status_code == 404 +``` + +The second test is the one that actually earns its keep: a test suite that only ever checks the +allowed path can pass at 100% coverage while the org filter above is missing entirely — see +[Testing](testing.md#the-sqlite-caveat) for why this particular pair of tests is also the only thing +proving the ORM filter is correct, independent of whether RLS is even enforced in the environment the +test runs in. diff --git a/docs/contributing/code-style.md b/docs/contributing/code-style.md new file mode 100644 index 00000000..b006e93e --- /dev/null +++ b/docs/contributing/code-style.md @@ -0,0 +1,91 @@ +# Code style + +## Python + +`backend/ruff.toml` exists and is a real, checked-in configuration: + +```toml +indent-width = 4 + +[format] +indent-style = "space" +line-ending = "lf" +quote-style = "double" + +[lint] +select = ["E", "F", "I"] +ignore = ["E501"] +``` + +That selects pycodestyle errors (`E`), pyflakes (`F`), and import sorting (`I`), and explicitly +ignores line-length (`E501`) — so this project has decided not to enforce a line-length limit, not +that it forgot to. + +**But read this before running anything:** as of this writing, none of `black`, `isort`, or `ruff` +is declared as a dependency anywhere in `backend/pyproject.toml` — not in `[project.dependencies]`, +not in the `dev` or `docs` groups under `[dependency-groups]`, not in the `mcp` extra, and not +pulled in transitively (`backend/uv.lock` has no entry for any of the three). `uv sync` on a fresh +clone will not install any of them, and `uv run black .` fails on a fresh clone with `error: Failed +to spawn: 'black' / Caused by: No such file or directory`. The same is true of `isort` and `ruff`. +CI does not run any of them either: +`.github/workflows/tests.yml`'s `backend-tests` job runs a migration check and `pytest`, and nothing +else — there is no lint or format step for Python anywhere in this repository's CI. In practice, +Python style in this project is currently enforced by human review, not tooling. + +Two things follow from that: + +1. **Don't `uv add black isort ruff` to make the documented commands work.** Adding a dependency + changes `pyproject.toml` and re-locks `uv.lock` for every future `uv sync` — including CI's — so + it's a decision to make deliberately, in its own PR, not something to smuggle in while fixing an + unrelated bug. See [Development setup](development-setup.md#backend) for what `uv add` actually + does. +2. **The codebase does not currently conform to what these tools would enforce**, so even running + them read-only produces a large, unrelated diff: `uvx ruff check .` — `uvx` runs a tool from + PyPI in an ephemeral environment without touching this project's lockfile — reports on the order + of 150+ findings across the tree today, and `uvx black --check --diff .` reports on the order of + 170 files that would be reformatted. Neither number is a defect to fix in passing; it reflects + that these tools have not been run consistently against this codebase's history. If you want to + check your own changes without installing anything project-wide, scope `uvx` to the files you + touched (`uvx ruff check path/to/your_file.py`, `uvx black --check path/to/your_file.py`) rather + than the whole tree — running `black .` or `ruff check --fix .` across everything will reformat + files you didn't mean to touch and turn a focused PR into an unreviewable one. + +Match the surrounding file's conventions by eye — 4-space indentation, double-quoted strings, +Django/DRF import grouping (standard library, then third-party, then local `common`/app imports) — +the way `ruff.toml`'s settings describe, even without the tool enforcing it for you. + +## Frontend + +`frontend/package.json` defines the real commands: + +```bash +pnpm run lint # prettier --check . && eslint . +pnpm run format # prettier --write . +pnpm run check # svelte-kit sync && svelte-check --tsconfig ./jsconfig.json +``` + +Unlike the backend, these tools are installed (`prettier`, `eslint`, `svelte-check`, and their +plugins are all in `frontend/package.json`'s `devDependencies`) and CI runs all three, but not +symmetrically: `.github/workflows/tests.yml`'s `frontend-checks` job runs the Prettier check with a +`|| echo "::warning::..."` fallback, so a formatting difference shows up as a CI **warning**, not a +failed build. The ESLint step (`pnpm eslint .`) has no such fallback — a lint error fails the job. +`pnpm check` (svelte-check) and `pnpm build` run unconditionally after that and also fail the job on +error. So in practice: fix ESLint errors and type errors before opening a PR, but don't be surprised +if Prettier differences are flagged as a warning rather than a hard failure — run `pnpm run format` +yourself rather than relying on CI to catch it. + +## Flutter + +```bash +cd mobile +dart format . +flutter analyze --no-fatal-infos +``` + +`mobile/analysis_options.yaml` includes `package:flutter_lints/flutter.yaml` with no +project-specific rules added or removed. Neither of this repository's two GitHub Actions workflows +(`tests.yml`, `codeql-analysis.yml`) mentions Flutter, Dart, or `mobile/` at all, so — like the +backend's formatters — nothing in CI enforces Dart formatting or lint cleanliness; running these +commands yourself before opening a PR is the only check that happens. See +[Build and configure](../mobile/build-and-configure.md#tests-and-analysis) for the full command set +including `flutter test`. diff --git a/docs/contributing/development-setup.md b/docs/contributing/development-setup.md new file mode 100644 index 00000000..3c214685 --- /dev/null +++ b/docs/contributing/development-setup.md @@ -0,0 +1,123 @@ +# Development setup + +This page gets a clone of the three BottleCRM projects — `backend/`, `frontend/`, `mobile/` — running +locally for development. For exact required versions (Python, PostgreSQL, Node, pnpm) see +[Requirements](../self-hosting/requirements.md); this page assumes you already have them installed. +For a from-scratch install walkthrough (including creating the database and `.env`), see +[Manual setup](../getting-started/manual-setup.md) — this page focuses on the parts specific to +contributing: running the test suite, adding dependencies, and running the mobile app alongside the +other two. + +## Backend + +The backend is managed with [`uv`](https://docs.astral.sh/uv/), not pip or a manually-activated +virtualenv. `uv sync` reads `backend/pyproject.toml` and `backend/uv.lock` and creates +`backend/.venv/` with the exact locked dependency versions; every command after that runs through +`uv run `, which resolves that venv automatically without you activating it. + +```bash +cd backend +uv sync +cp .env.example .env +uv run python manage.py migrate +uv run python manage.py runserver +``` + +Edit `.env` first if your local PostgreSQL role, database name, or Redis URL differ from the +defaults — see [Manual setup](../getting-started/manual-setup.md) and +[Environment variables](../self-hosting/environment-variables.md). + +Run the test suite from `backend/` — see [Testing](testing.md) for the fast-iteration flags and +what the markers mean: + +```bash +uv run pytest +``` + +To add a dependency, use `uv add`, not a manual edit to `pyproject.toml` — `uv add` resolves the +version, writes it to `pyproject.toml`, and re-locks `uv.lock` in one step, so the two files never +drift out of sync: + +```bash +uv add # runtime dependency, added to [project.dependencies] +uv add --group dev # dev-only dependency (tests, tooling), added to [dependency-groups] +``` + +To refresh every locked version against the current `pyproject.toml` constraints without adding or +removing a dependency — for example, to pick up a transitive security fix — use `uv lock --upgrade` +instead; it re-resolves and rewrites `uv.lock` in place. + +`backend/pyproject.toml` currently declares two dependency groups — `dev` (`pytest`, +`pytest-django`, `pytest-cov`) and `docs` (`mkdocs-material`) — plus one optional extra, `mcp`, for +the hosted MCP server (`uv sync --extra mcp`, not installed by default). `dev` is treated as a +default group by `uv`, so `uv sync` on its own installs it; a plain `uv sync --group docs` installs +`docs` **in addition to** `dev`, not instead of it — that's why CI's `docs-build` job, which runs +exactly `uv sync --frozen --group docs`, is able to run `uv run pytest ...` in the very next step +(see [Pull requests](pull-requests.md#what-ci-runs)). `mcp` is the one group/extra that stays out +unless you ask for it explicitly with `--extra mcp`. See [Code style](code-style.md) before reaching +for `uv add black` or similar — it is not as simple as it sounds in this repository. + +## Frontend + +The frontend uses [pnpm](https://pnpm.io/), pinned to major version 10 in CI +(`.github/workflows/tests.yml`). + +```bash +cd frontend +pnpm install +pnpm run dev +``` + +`pnpm run dev` starts the Vite dev server on `http://localhost:5173`. `frontend/vite.config.js` +configures no dev-server proxy — there is no `server.proxy` entry in it — so the browser calls the +Django API directly, cross-origin: `frontend/src/lib/api.js` builds its base URL from +`PUBLIC_DJANGO_API_URL`, defaulting to `http://localhost:8000/api` when that variable is unset. That +means your backend's `CORS_ALLOWED_ORIGINS` has to include `http://localhost:5173` (it does by +default in `backend/.env.example` — see [Manual setup](../getting-started/manual-setup.md)) or every +request from the dev server fails as a CORS error, not a 404 or connection-refused, which is easy to +misdiagnose as a backend problem. + +## Mobile + +```bash +cd mobile +flutter pub get +flutter run +``` + +That gets the app compiling and running against whatever emulator or device is attached, but **it +will not talk to your local backend until you edit a compile-time constant first** — see +[Build and configure](../mobile/build-and-configure.md) and +[Connect to a self-hosted backend](../mobile/connect-to-self-hosted.md) before running it if you +intend to exercise real API calls rather than just confirm the app builds. + +## Running everything + +The fastest way to bring up the full stack — PostgreSQL, Redis, the Django API, a Celery worker, a +Celery beat scheduler, and the frontend dev server — together is Docker Compose, from the repo +root: + +```bash +docker compose up --build +``` + +See [Docker](../self-hosting/docker.md) for what each service does and how to reach them. Running +the pieces natively instead, for faster iteration on the backend or frontend, means starting +PostgreSQL and Redis yourself and then running each of these in its own terminal: + +```bash +# Terminal 1 — API +cd backend && uv run python manage.py runserver + +# Terminal 2 — background jobs (recurring invoices, SLA scans, notification emails, ...) +cd backend && uv run celery -A crm worker --loglevel=INFO + +# Terminal 3 — frontend +cd frontend && pnpm run dev +``` + +The Celery worker is optional for exercising most CRUD endpoints, but several features send email or +run scheduled work through it — see [Background jobs](../architecture/background-jobs.md) for what +runs where, and note that `celery -A crm beat` (the scheduler that actually fires the ten recurring +jobs described there) is a fourth, separate process that Docker Compose starts for you but the +native flow above does not. diff --git a/docs/contributing/pull-requests.md b/docs/contributing/pull-requests.md new file mode 100644 index 00000000..a4f8bd83 --- /dev/null +++ b/docs/contributing/pull-requests.md @@ -0,0 +1,81 @@ +# Pull requests + +## Before you open one + +`CONTRIBUTING.md` (repo root) lays out the process this page assumes: branch from `master` with a +descriptive name, keep the change focused rather than mixing in unrelated edits, include tests where +practical, and update documentation when behavior, setup, or a public API changes. Its own checklist +is worth re-reading right before you open a PR: + +- The change is focused and does not include unrelated edits. +- Relevant backend and frontend checks pass — see [Development setup](development-setup.md) and + [Testing](testing.md) for what to run locally before pushing. +- Tests cover new or changed behavior where practical — see + [Security rules](security-rules.md#prove-permission-checks-can-fail) specifically if the change + touches a permission check. +- Required migrations are included — see [What CI runs](#what-ci-runs) below for what happens if + one is missing. +- Documentation is updated. +- No credentials, tokens, or private data are committed. + +In the PR description itself: explain what changed and why, how you tested it, and include +screenshots for any visible UI change. + +## What CI runs + +`.github/workflows/tests.yml` defines three jobs — `backend-tests`, `frontend-checks`, and +`docs-build` — triggered by two events, both scoped to the same path list (`backend/`, `frontend/`, +`docs/`, `mkdocs.yml`, `.readthedocs.yaml`, `.coveragerc`, or the workflow file itself): a pull +request against `master`, or a **push to `master` specifically** (`branches: [master]` on the `push` +trigger). Pushing a feature branch on its own doesn't run any of this — you'll only see these jobs +once you open the PR against `master`, so don't expect CI feedback from `git push` alone. + +**`backend-tests`** installs WeasyPrint's system libraries, runs `uv sync --frozen` (installing +exactly what's locked in `uv.lock` — no re-resolution), then two steps in order: + +```bash +uv run python manage.py makemigrations --check --dry-run +uv run pytest -m "not postgres_only" --tb=short -v +``` + +**A model change without a matching migration fails the first step**, before the test suite even +runs — `makemigrations --check --dry-run` exits non-zero if Django's autodetector finds any model +change that isn't already captured in a migration file. Generate and commit the migration +(`uv run python manage.py makemigrations`) before you push. See [Testing](testing.md#what-ci-runs) +for what the test step does and doesn't cover — notably, this job runs on SQLite, so it exercises no +RLS policy regardless of what you changed. On a push to `master` (not on pull requests) this job +additionally regenerates and commits a coverage badge. + +**`frontend-checks`** installs with `pnpm install --frozen-lockfile`, then runs a Prettier check (a +formatting difference is reported as a warning, not a failure — see +[Code style](code-style.md#frontend)), `pnpm eslint .` (this one does fail the job), `pnpm check` +(svelte-check), and finally `pnpm build`. + +**`docs-build`** installs the `docs` dependency group (`uv sync --frozen --group docs`) and runs + +```bash +uv run mkdocs build --strict -f ../mkdocs.yml -d site +``` + +`--strict` fails the build on a broken nav entry or a broken internal link target that doesn't +exist. A second step then runs `common/tests/test_docs_build_excludes_internal.py`, which rebuilds +the site again and asserts that nothing under `docs/superpowers/` or `docs/design/` — internal specs +and plans, excluded via `exclude_docs` in `mkdocs.yml` — ends up published in the output. If you add +a documentation page, `mkdocs build --strict` is the same command you can run locally first; see +[Development setup](development-setup.md#backend) for the `uv run --group docs` invocation. + +## Review expectations + +Backend Python style is not enforced by CI (see [Code style](code-style.md#python) for why) — a +reviewer may still ask you to match the surrounding file's formatting even though no bot will flag +it automatically. Frontend style mostly is enforced (ESLint failures and type errors block the +build; Prettier differences only warn). Flutter has no CI at all, so `dart format .` and `flutter +analyze` before opening a PR are the only check that happens for mobile changes. + +Beyond style, expect review to focus on the two things this codebase treats as non-negotiable: every +new or changed queryset filtering by `org=request.profile.org` (or being covered by RLS with a test +proving it — see [Adding an org-scoped model](adding-an-org-scoped-model.md)), and every new or +changed permission check being provable in both directions — see +[Security rules](security-rules.md#prove-permission-checks-can-fail). A PR that adds an endpoint +without a test for the rejected/forbidden path, not just the happy path, is the single most common +thing to get asked to add here. diff --git a/docs/contributing/repo-layout.md b/docs/contributing/repo-layout.md new file mode 100644 index 00000000..7d567c78 --- /dev/null +++ b/docs/contributing/repo-layout.md @@ -0,0 +1,105 @@ +# Repository layout + +This page is a map, not a tutorial — it tells you where to look, and links to the pages that explain +*why* each part is shaped the way it is. + +## Top level + +``` +Django-CRM/ +├── backend/ # Django REST Framework API — see Backend apps below +├── frontend/ # SvelteKit web app — see Frontend below +├── mobile/ # Flutter app — see Mobile below +├── docs/ # This documentation site (MkDocs Material) +├── docker/ # Docker-specific config: docker/postgres/init-rls-user.sql (creates the +│ # non-superuser RLS role) and docker/backend/entrypoint.sh — that's the +│ # whole directory, no Redis config lives here +├── mcp_server/ # The hosted MCP server (backend's optional `mcp` extra) — see +│ # Integrations → MCP server +├── mkdocs.yml # Docs site config and nav +├── docker-compose.yml # Full local stack — see Self-hosting → Docker +├── CONTRIBUTING.md # The short version of this Contributing section +├── RLS_SETUP.md # Stubbed pointer to PostgreSQL and RLS — kept only so links made before +│ # this documentation site existed still resolve; not a setup guide +│ # itself anymore +└── schema.yml # Generated OpenAPI schema (drf-spectacular) — not hand-maintained +``` + +`docs/` also holds internal specs and plans under `docs/superpowers/` and `docs/design/` — these are +excluded from the built site (`exclude_docs` in `mkdocs.yml`) and are not part of the published +documentation. + +## Backend apps + +`backend/crm/settings.py`'s `INSTALLED_APPS` lists the project's Django apps in this order (minus +Django/DRF/third-party entries): + +| App | Owns | +|---|---| +| `common` | `User`/`Org`/`Profile` models, the RLS configuration, permission classes, every authentication path (JWT, personal access tokens, org API keys), notifications, tags, teams, custom fields, documents, and vertical packs | +| `accounts` | Account (company/customer) records | +| `cases` | Support tickets: `Case`, SLAs, CSAT, escalation/routing, inbound email | +| `contacts` | Contact (person) records | +| `leads` | Leads, lead pipelines/stages, lead-to-account conversion | +| `opportunity` | The sales pipeline: `Opportunity`, line items, stage aging, sales goals/quotas | +| `tasks` | Task management and Kanban boards (`Board`/`BoardColumn`/`BoardTask`) | +| `invoices` | Billing — invoices, estimates, recurring invoices, products, payments — and the anonymous client-portal views | +| `orders` | Sales orders and order line items | +| `business_hours` | Per-org working-hours calendars that SLA timers honor | +| `macros` | Canned responses / saved replies | + +`common` is the one app every other app depends on for tenancy: it owns `BaseModel` and +`BaseOrgModel` (`common/base.py` — the abstract base classes every org-scoped model builds on, see +[Adding an org-scoped model](adding-an-org-scoped-model.md)), the tenancy middleware +(`common/middleware/get_company.py`'s `GetProfileAndOrg` and +`common/middleware/rls_context.py`'s `RequireOrgContext`), and the RLS utilities +(`common/rls/__init__.py` — `ORG_SCOPED_TABLES`, `get_enable_policy_sql`, and the rest of the SQL +generators). See [Architecture overview](../architecture/overview.md#backend-layout) for how these +pieces run together on a request, and [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md) +for the tenancy model itself. + +Each domain app follows a similar internal shape: `models.py` (or a `models/` package for larger +apps), `serializer.py`, one or more view modules, `migrations/`, and `tests/`. `common` is larger and +less uniform than the others because it also holds cross-cutting views — the dashboard, global +search, tag usage counts — that pull in models from every domain app; see +[Architecture overview](../architecture/overview.md#backend-layout) for why that one-directional +model-layer rule doesn't extend to views. + +## Frontend + +``` +frontend/src/ +├── lib/ +│ ├── api.js, api-helpers.js # API client (fetch/axios wrappers) +│ ├── components/ # Shared UI components (shadcn-svelte based) +│ ├── server/ # Server-side load-function helpers +│ ├── shell/ # App shell (nav, layout chrome) +│ ├── stores/ # Svelte stores +│ └── v2/ # Shared v2 design-system pieces (components, styles, formatters) +└── routes/ + ├── (app)/ # Authenticated routes — leads, accounts, contacts, opportunities + │ # (as "pipeline"), tasks, tickets ("support"), invoices, and more + ├── (no-layout)/ # Public routes — login and the rest of the pre-auth flow + └── api/ # SvelteKit server routes (not the Django API) +``` + +Every page under `routes/(app)/` reads and writes the Django API described in the rest of this +site — there is no separate frontend-only data model. + +## Mobile + +``` +mobile/lib/ +├── config/ # API base URL and other compile-time configuration +├── core/ # Cross-cutting utilities +├── data/ # Data layer +├── providers/ # State management +├── routes/ # App routing +├── screens/ # UI screens +├── services/ # API and auth service classes +├── widgets/ # Shared widgets +└── main.dart # Entry point +``` + +See [Build and configure](../mobile/build-and-configure.md) for how this connects to a backend, and +[Release builds](../mobile/release-builds.md) for producing a signed build. diff --git a/docs/contributing/security-rules.md b/docs/contributing/security-rules.md new file mode 100644 index 00000000..8a975ddf --- /dev/null +++ b/docs/contributing/security-rules.md @@ -0,0 +1,182 @@ +# Security rules + +BottleCRM is a multi-tenant application where every org's data sits in the same database, the same +tables, next to every other org's. The rules on this page exist because the two ways that goes wrong +— one tenant reading or writing another tenant's data, and a non-admin reaching admin- or +platform-level actions — are the two outcomes every change touching auth, permissions, or +serializers has to rule out before it merges. + +## The trust boundary + +The backend (`backend/`) is the only trust boundary in this system. +[Architecture overview](../architecture/overview.md#the-three-clients) describes why that has to be +true structurally, not just as policy: a SvelteKit web app, a Flutter mobile app, and any +third-party integration all talk to the *same* Django REST Framework API over the same JSON +endpoints — there is no separate, more-trusted internal API. A rule enforced only in the SvelteKit +UI (disabling a button, hiding a form field, client-side validation) protects none of the three +clients, because a browser DevTools console, a modified mobile build, or a plain `curl` request +reaches the same endpoint directly, skipping whatever the UI would have prevented. Every constraint +that matters has to be enforced in the DRF view or serializer, full stop. + +## Identity is server-derived + +Who is making a request, which org they're acting in, and what role they hold are facts the server +establishes and re-validates on every request — never values read from the request body, a query +string, or a custom header. [Authentication](../architecture/authentication.md#claims) shows the +concrete mechanism: the `org_id` claim inside a caller's signed JWT is not simply trusted — the +middleware that resolves it re-checks it against an active `Profile` row on every single request: + +```python +profile = Profile.objects.select_related("org").get( + user_id=user_id, org_id=org_id, is_active=True +) +``` + +If that lookup fails — the membership was revoked, the profile was deactivated — the request gets a +403 asking the caller to sign in again, rather than proceeding on a claim that's gone stale. The +practical rule for any view or serializer you write: identity comes from `request.user` and +`request.profile`, both set by middleware before your code runs (see +[Overview](../architecture/overview.md#request-lifecycle)) — never from a field the client sent. +`role`, in particular, is a display convenience when it appears in a JWT payload; every real +permission check reads `request.profile.role` fresh from the database, not from the token. + +## Tenant scoping + +Pair every permission check with an explicit org filter — the two are independent controls, and +neither substitutes for the other. `HasOrgContext` (`backend/common/permissions.py`) is the standard +permission class on nearly every org-scoped view: + +```python +class HasOrgContext(permissions.BasePermission): + def has_permission(self, request, view): + if not hasattr(request, "profile") or request.profile is None: + return False + if not hasattr(request, "org") or request.org is None: + return False + if not request.profile.is_active: + return False + return True +``` + +That establishes "a real, active member of this org is making this request" — it does not filter +which rows the view returns. Every queryset still has to add +`Model.objects.filter(org=request.profile.org)` itself, and every `create()` / `serializer.save()` +still has to pass `org=request.profile.org` explicitly, never accept it from the request body. RLS +(PostgreSQL Row-Level Security) backs this up at the database layer, but it's a safety net for when +the filter is missing, not a substitute for writing it — see +[The two-layer contract](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) for why +both layers are required, and [Adding an org-scoped model](adding-an-org-scoped-model.md) for the +full checklist when the view is for a brand-new model. + +## Server-owned fields + +Any field the server is supposed to derive — `org`, `created_by`, `profile`, a publish/visibility +flag — must never be settable from `request.data`. There are two real mechanisms in this codebase +for that, not one: + +**Declare the field `read_only=True` or list it in `read_only_fields`.** `TeamCreateSerializer` in +`backend/common/serializer.py` declares `read_only_fields = ("created_at", "created_by", "org")`; +`leads/serializer.py` sets `read_only_fields = ("id", "created_at", "updated_at", "org")` on +`LeadStageSerializer` and `LeadPipelineSerializer`; and `TimeEntrySerializer` +(`backend/cases/serializer.py`) declares the field itself read-only — `profile = +ProfileSerializer(read_only=True)` — for exactly the reason this section opens with: `profile` is +who logged the time entry, and it has to be a fact the server sets, not one a client requests. + +**Or don't accept it through the serializer at all, and set it explicitly at the call site.** +`TimeEntryCreateSerializer`'s own docstring states this directly: "`case`, `profile`, and `org` are +injected by the view and not accepted from the client" — and indeed none of the three appear in its +`Meta.fields`. The view (`backend/cases/time_views.py`) backs that up: + +```python +entry = TimeEntry.objects.create( + org=request.profile.org, + case=case, + profile=request.profile, + **serializer.validated_data, +) +``` + +`serializer.validated_data` can only ever contain the fields the serializer declared in the first +place, so there's nothing for a client to override here even by guessing field names — the +equivalent of popping a key out of `request.data` before `save()`, but enforced by what the +serializer's `fields` tuple omits rather than by a runtime pop. + +The sharpest example of *why* this matters is `is_sample` — a flag shared, via one imported +`SAMPLE_DATA_HELP_TEXT` constant, by six models: `Lead`, `Account`, `Contact`, `Case`, `Opportunity`, +and `Task` (`grep -rl SAMPLE_DATA_HELP_TEXT backend/` finds it in each app's `models.py`, plus +`common/base.py` where it's defined once). `leads/serializer.py` declares +`read_only_fields = ("is_sample",)`. The help text itself, defined once in `backend/common/base.py` +and shared by every model that has the field, states the reasoning as a security invariant rather +than documentation: + +```python +SAMPLE_DATA_HELP_TEXT = ( + "True only for demo rows created by common.packs.applier._apply_sample_data. " + "Server-set exclusively — never expose this as a writable field on any " + "serializer. It is the sole key common.packs.applier.clear_sample_data uses " + "to decide what to delete, so a client-writable path here would let a user " + "mark an arbitrary real record as sample and have it deleted." +) +``` + +If a field like this were writable, a hostile request wouldn't need a bug in the delete logic at +all — it would just flip the flag on someone else's real record and let the legitimate "clear sample +data" action do the deleting. When you add a field the server is meant to own, ask what a client +could achieve by setting it directly, not just whether the happy path needs it to be read-only. + +## Prove permission checks can fail + +A permission check that always evaluates to the same answer isn't a check — it's dead code wearing +a check's shape, and this codebase has a well-documented history of exactly that happening by +accident. `Case.created_by` (like nearly every model's `created_by`) is a foreign key to `User`, not +to `Profile`. Comparing a `Profile` instance directly against it — + +```python +request.profile == self.object.created_by # always False: Profile compared to a User FK +``` + +— type-checks in Python and never raises, but is never `True`, which silently turned an intended +"creator or admin" check into "admin only" with no error anywhere. This isn't hypothetical in this +codebase: `git log -L` on `CaseAttachmentView.delete` in `backend/cases/views.py` shows a commit +(`c9250dd`) whose diff replaces exactly this — + +```diff +- request.profile.role == "ADMIN" +- or request.profile.is_admin +- or request.profile == self.object.created_by ++ is_org_admin(request.profile) ++ or request.profile.user_id == self.object.created_by_id +``` + +— confirming from the actual commit history, not from a comment describing it, that the +`Profile`-vs-`User` comparison was live code before it was replaced with the type-correct +`request.profile.user_id == self.object.created_by_id`. + +[Permissions and roles](../architecture/permissions-and-roles.md#object-level-checks) documents this +pattern in depth, including which apps had it fixed and which — as of that page's own last check +against source — still don't; read it before writing a new `created_by` comparison, and compare +`profile.user_id` to `obj.created_by_id`, never `profile` to `obj.created_by` directly. + +The only way to know a check can fail is to write a test that makes it fail. `leads/tests/ +test_lead_access_and_totals.py` states the rule its own tests follow: "Every permission test asserts +both directions. A check that can only return False is indistinguishable from a check that is never +reached." In practice that means a pair of tests, not one — for example +`opportunity/tests/test_sales_goals.py`: + +```python +def test_create_goal_admin(self, admin_client, org_a): + ... + response = admin_client.post(self.GOALS_URL, data, format="json") + assert response.status_code == 201 + +def test_create_goal_non_admin_forbidden(self, user_client): + ... + response = user_client.post(self.GOALS_URL, data, format="json") + assert response.status_code == 403 +``` + +A single test asserting only the `403` case would pass identically whether the admin check works +correctly or whether it denies *everyone*, admin included, because of a mistake like the one above. +Only the paired `True` test catches that. See [Testing](testing.md) for the fast-iteration commands, +and [Adding an org-scoped model](adding-an-org-scoped-model.md#the-tests) for the same pairing +applied to tenant isolation specifically. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 00000000..70f88e31 --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,113 @@ +# Testing + +## Running tests + +From `backend/`: + +```bash +uv run pytest +``` + +`backend/pytest.ini` sets `addopts` to include coverage by default — +`--cov=. --cov-config=../.coveragerc --cov-report=term-missing --cov-report=html:htmlcov +--cov-report=xml:coverage.xml` — alongside `--strict-markers --tb=short -v`. So a plain `uv run +pytest` always computes coverage and writes `backend/htmlcov/` and `backend/coverage.xml`, even for +a single test file. + +Scope a run to one app, one file, or a keyword the same way you would with any pytest project: + +```bash +uv run pytest accounts/tests/ # one app's test package +uv run pytest leads/tests/test_leads_kanban.py # one file +uv run pytest -k "test_org_isolation" # by keyword — matches the cross-tenant + # isolation test present in most API test + # modules (leads, cases, contacts, ...) +``` + +## Fast iteration + +Coverage instrumentation and the three report formats above add real overhead. For the +edit-run-edit loop, drop coverage and stop at the first failure: + +```bash +uv run pytest --no-cov -x +``` + +`--strict-markers` (also set in `addopts`) means a typo'd **custom** marker fails the run immediately +rather than being silently ignored — every custom marker you use has to be declared in `pytest.ini`'s +`markers` list. It doesn't touch markers a plugin registers itself, like `@pytest.mark.django_db` +(from `pytest-django`) — those work without appearing in `pytest.ini` at all, which is why every +example on this page uses `django_db` freely alongside the two markers that actually are declared. + +## Markers + +`pytest.ini` declares exactly two custom markers: + +```ini +markers = + slow: marks tests as slow + postgres_only: marks tests that require PostgreSQL +``` + +`postgres_only` is in active use — exactly six tests carry it, confirmed by collecting the marker +directly (`uv run pytest -m postgres_only --collect-only` → `6/2776 tests collected`), across four +files: `common/tests/test_pat_auth.py` (one test), `common/tests/test_pat_model.py` (one), +`common/tests/test_rls_pipeline_tables.py` (two), and `invoices/tests/test_portal_rls.py` (two). +(`common/tests/test_pat_integration.py` mentions `postgres_only` only in a comment explaining that +its own tests deliberately *don't* carry the marker and run on SQLite instead — it contributes zero +of the six.) Four of the six assert RLS policy state directly (`pg_policies`, +`pg_class.relrowsecurity`) — the two in `test_rls_pipeline_tables.py`, plus one each in +`test_pat_model.py` and `test_portal_rls.py` confirming `personal_access_token` and +`portal_access_token` deliberately carry *no* policy. The other two assert isolation *behaviour* +under a real, RLS-enforcing role instead of reading policy metadata: +`test_pat_auth.py::test_cross_org_pat_cannot_read_other_orgs_leads` calls the real `/api/leads/` +endpoint and checks a foreign org's lead never appears, and +`test_portal_rls.py::test_resolution_reads_estimate_that_empty_context_hides` exercises the full +portal-token resolution path. `slow` is declared but, +as of this writing, applied to nothing (`grep -rn "pytest.mark.slow" --include='*.py' .`, excluding +`venv/`, returns no matches in application code) — it's reserved for future use, not a marker you'll +see filtering anything today. + +## The SQLite caveat + +`crm.test_settings` (`backend/crm/test_settings.py`) is the settings module `pytest.ini` points at, +and its `DATABASES` is hardcoded to an in-memory SQLite database — not read from an environment +variable, not conditional on anything. Its own module docstring states why: "RLS (Row-Level +Security) is PostgreSQL-only and is skipped on SQLite." That means **the default `uv run pytest` +run never exercises a single RLS policy** — every test runs against a database engine that has no +concept of row security at all. Everything you'd normally rely on RLS to catch, an org-filter bug +included, is invisible to this default suite; only the explicit `Model.objects.filter(org=...)` in +application code is being tested. See [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md#the-two-layer-contract) +for why that ORM filter has to be correct on its own merits, independent of RLS. + +The `postgres_only`-marked tests exist to close part of that gap, and they self-skip safely rather +than fail when there's no PostgreSQL connection: each one checks `connection.vendor != "postgresql"` +and calls `pytest.skip(...)` if so. Under the SQLite settings this repository ships, that check is +always true, so these six tests always skip — they don't fail, and a green `uv run pytest` run tells +you nothing about whether they'd pass. One of them — `test_portal_rls.py`'s module docstring — +describes an intended way to actually run them for real, against a non-superuser PostgreSQL role: +`RLS_ENFORCE=1`, pointed at a `crm.test_settings_postgres` module. A comment in `backend/.env` +(gitignored — `.gitignore:20` — and absent from `.env.example`, so a fresh clone has neither the +file nor the reference) names the same two pieces. Neither source is authoritative, because that +settings module does not exist anywhere in this checkout as of this writing, so this documented path +does not currently work. If you need to prove RLS isolation holds against real data rather than +just reading the policy SQL, use `manage.py manage_rls --test` against a real, non-superuser-configured PostgreSQL +database instead — see [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#verifying-isolation) +for exactly what it proves and how to set up that role. + +## What CI runs + +`.github/workflows/tests.yml`'s `backend-tests` job runs on the same SQLite settings described +above — there's no PostgreSQL service in that job — so `postgres_only` tests skip there too, and +CI itself has no path that runs them for real. Two steps matter, in this order: + +```bash +uv run python manage.py makemigrations --check --dry-run +uv run pytest -m "not postgres_only" --tb=short -v +``` + +The migration check runs first and fails the build if any model change is missing its migration — +see [Pull requests](pull-requests.md#what-ci-runs) for what that means for your workflow. The +`-m "not postgres_only"` filter on the test step is redundant with the tests self-skipping, but +makes the exclusion explicit in the command itself rather than relying on every future +`postgres_only` test remembering its own skip guard. diff --git a/docs/getting-started/demo-data.md b/docs/getting-started/demo-data.md new file mode 100644 index 00000000..4b1b931a --- /dev/null +++ b/docs/getting-started/demo-data.md @@ -0,0 +1,101 @@ +# Demo data and packs + +BottleCRM has two independent ways to populate an organization with non-empty data: the +`seed_data` management command, which generates a large, randomized dataset for an entire org, and +**vertical packs**, which apply a small, curated, industry-specific set of pipelines and sample +records to an org that already exists. + +## Seeding a demo organization + +```bash +uv run python manage.py seed_data --email you@example.com +``` + +Run from `backend/` (or `docker compose exec backend python manage.py seed_data --email +you@example.com` against the Docker stack). This is the command referenced in +[Docker quick start](docker-quick-start.md) and [First sign-in](first-sign-in.md). + +The command (`backend/common/management/commands/seed_data.py`) creates an organization and fills +it with realistic-looking fictional data — contacts, accounts, leads, opportunities, cases, tasks, +products, invoices, estimates, recurring invoices, sales goals, teams and tags — using curated +name/company/address pools and reserved demo domains (`example.com`, `.example`) so nothing it +generates resembles a real person or business. The **first** organization it creates is always +named **`MicroPyramid`**, specifically so local-dev workflows have a stable, known org name to +sign in against with `devlogin --org MicroPyramid`. An **admin profile** is created for whichever +`--email` you pass. Re-running the command with the same email reuses the existing org and user +rather than creating duplicates — it's safe to run more than once. + +Counts for every entity type (`--leads`, `--accounts`, `--opportunities`, `--invoices`, and so on) +and locale (`--currency`, `--country`) are configurable flags; see `--help` for the full list. A +`--clear` flag deletes existing CRM data for re-seeding (but never users, orgs or profiles) — it's +a separate, broader operation from the sample-data clearing described below, and is not scoped to +records a pack created. + +## Vertical packs + +A vertical pack is a small, declarative JSON manifest — not a data generator — that shapes an org +toward one industry: a starter pipeline (with stages) for leads, cases and tasks; a handful of +custom field definitions; tags; products; some terminology overrides (for example relabeling +"Lead" as "Enquiry"); and a short list of named sample records (accounts, contacts, deals, +tickets, leads, tasks) that reference each other by name within the manifest. + +Four packs ship in `backend/packs/`, one manifest file per pack: + +| Pack id | File | Description | +|---|---|---| +| `real-estate` | `real-estate.json` | Brokers, agencies and developers selling residential and commercial property. | +| `professional-services` | `professional-services.json` | Agencies, consultancies and accounting firms selling billable work. | +| `education-admissions` | `education-admissions.json` | Coaching centres, training institutes and colleges managing admissions. | +| `events-weddings` | `events-weddings.json` | Wedding planners, event agencies and venues taking enquiries through to a delivered event. | + +Every manifest is validated strictly at load time (`backend/common/packs/schema.py`) — an unknown +key, an invalid `stage_type`, a `sample_data` reference to an account or stage name that isn't +declared elsewhere in the same pack, and similar mistakes all fail validation rather than being +silently ignored or half-applied. + +## Applying a pack + +Applying a pack is handled by `backend/common/packs/applier.py`'s `apply_pack()`, reachable +through: + +- **The UI** — the Settings → Organization page (for an existing org) and the organization + creation page (for a brand-new org) both offer a pack picker, calling the same backend endpoint. +- **The API** — `POST /api/packs//apply/`, restricted to an organization admin + (`request.profile.role == "ADMIN"`); any authenticated member can list available packs via + `GET /api/packs/`, but only an admin can apply one. + +**Applying a pack is strictly additive.** The applier's own description of itself: it creates +what's missing and skips what already exists, and it "never calls `.update()`, never calls +`.save()` on a row it did not just construct, and never deletes." A pipeline whose name already +exists in the org is skipped entirely (along with its stages); a custom field, tag or product that +already exists by its matching key is skipped individually. This makes applying the same pack +twice — or applying a second pack to an org that already has one — safe: it can only add what's +still missing, never overwrite or remove something you've since changed. The one exception is +terminology and the org's `vertical` label, which are merged in (only keys not already set are +added) rather than duplicated. + +Sample records created by a pack (accounts, contacts, deals/opportunities, tickets/cases, leads +and tasks) are marked with an `is_sample` boolean field, set only by the applier — it is +`read_only` on every serializer that exposes it, so it can never be set by a client request. Every +sample row is also tagged with a "Sample data" tag and assigned to whichever admin applied the +pack, purely for visibility in the UI; the tag carries no special authority and isn't what the +system uses to know a row is sample data. + +If an org already has any sample rows (from this pack or a previous one), applying `sample_data` +again is skipped — a pack's sample data is only ever created once per org, regardless of how many +times you re-apply. + +## Clearing sample data + +**Only `clear_sample_data()` deletes sample records**, via `DELETE /api/packs/sample-data/` +(also admin-only, also reachable from the Settings → Organization page). It is the sole +counterpart to pack-applied sample data: nothing else in the system deletes rows just because +`is_sample` is set. + +Clearing is scoped strictly to rows where `is_sample=True` for the calling org, deleted in an +order that removes child records (tasks, leads) before the parents they can reference (tickets, +deals, contacts, accounts). It never removes anything else — a real record you created yourself is +untouched even if it happens to reference a sample record, and a sample row is **retained rather +than deleted** if a non-sample record still depends on it (for example, a real invoice raised +against a demo account). Retained rows are reported back, not silently skipped, so the caller +knows some sample data was intentionally kept rather than assuming the clear was incomplete. diff --git a/docs/getting-started/docker-quick-start.md b/docs/getting-started/docker-quick-start.md new file mode 100644 index 00000000..b2f9b32b --- /dev/null +++ b/docs/getting-started/docker-quick-start.md @@ -0,0 +1,136 @@ +# Docker quick start + +The fastest way to run BottleCRM locally is Docker Compose. It brings up PostgreSQL, Redis, the +Django API, two Celery processes and the SvelteKit frontend from one file, using the checked-in +`.env.docker` for configuration. + +## Prerequisites + +- Docker Engine with the Compose v2 plugin (the `docker compose` subcommand, not the standalone + `docker-compose` binary — `docker-compose.yml` is written for the plugin). +- A clone of the repository, with `docker-compose.yml` at its root. + +## Clone and configure + +All commands below run from the repository root — the directory that contains +`docker-compose.yml` and `Dockerfile`. + +`.env.docker` is checked into the repository with working development defaults and is loaded by +every service in `docker-compose.yml`. You don't need to copy or edit anything to get a working +stack. If you do want to override a value locally (for example to add Google OAuth credentials), +create `.env.docker.local` next to it — `docker-compose.yml` loads it as an optional, gitignored +second `env_file` whose values win over `.env.docker`. + +The variables `.env.docker` actually defines are: + +| Variable | Purpose | +|---|---| +| `SECRET_KEY` | Django secret key. The shipped value is an insecure dev placeholder. | +| `DEBUG` | `True` in the dev compose file — this is also what allows `devlogin` to run (see [First sign-in](first-sign-in.md)). | +| `ENV_TYPE` | `dev`. | +| `ALLOWED_HOSTS` | `localhost,127.0.0.1,backend`. | +| `DOMAIN_NAME`, `SWAGGER_ROOT_URL` | Both `http://localhost:8000`. | +| `DBNAME`, `DBUSER`, `DBPASSWORD`, `DBHOST`, `DBPORT` | Connection details the Django `backend` (and Celery) containers use to reach `db`. | +| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | Used only by the `db` service itself to create the database and its own superuser role. | +| `CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND` | Both point at the `redis` service, database `0`. | +| `DEFAULT_FROM_EMAIL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` | Console email backend (dev), and the credentials used to bootstrap a Django admin superuser — see [What you got](#what-you-got). | +| `CORS_ALLOW_ALL` | `True` for local development. | +| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` | Blank by default; Google sign-in is disabled until these are set. | +| `PUBLIC_DJANGO_API_URL` | The API base URL the frontend container is built against, `http://localhost:8000`. | + +## Start the stack + +```bash +docker compose up --build +``` + +This builds and starts six services, defined in `docker-compose.yml`: + +| Service | Image / build | Host port | +|---|---|---| +| `db` | `postgres:16-alpine` | `5432` | +| `redis` | `redis:7-alpine` | `6379` | +| `backend` | built from the repo root `Dockerfile` | `8000` | +| `celery-worker` | same image as `backend`, runs `celery -A crm worker --loglevel=info` | — | +| `celery-beat` | same image as `backend`, runs `celery -A crm beat --loglevel=info` | — | +| `frontend` | built from `frontend/Dockerfile` | `5173` | + +`backend`, `celery-worker` and `celery-beat` all wait for `db` and `redis` to report healthy +before starting. The `backend` container's entrypoint (`docker/backend/entrypoint.sh`) waits for +PostgreSQL to accept connections, runs `migrate`, runs `create_default_admin`, collects static +files, and then starts the Django dev server on `0.0.0.0:8000`. + +**The database role matters.** `db` starts as the `postgres` superuser (from `POSTGRES_USER`) — +that account exists only so the container can create the `crm_db` database. On first boot, +`docker/postgres/init-rls-user.sql` runs automatically (via Postgres's +`/docker-entrypoint-initdb.d/` mechanism) and creates a second, non-superuser role, `crm_user`, +granting it the privileges it needs on `crm_db`. The Django containers connect as `crm_user` +(`DBUSER` in `.env.docker`), never as `postgres`. This is required, not incidental: PostgreSQL +Row-Level Security is bypassed entirely for a superuser connection, so if the app ever connected +as `postgres`, every RLS policy in the schema would be silently inert and tenant isolation would +depend on application-level filtering alone. + +## Migrate and seed + +`entrypoint.sh` already runs `migrate` on every container start, so the schema is up to date as +soon as `backend` reports it's serving. To load demo data, run the seeder inside the running +`backend` container: + +```bash +docker compose exec backend python manage.py seed_data --email you@example.com +``` + +`seed_data` creates an organization named **`MicroPyramid`** (the first organization it creates +is always named this, specifically so local-dev workflows have a known, stable name to sign in +against) and an **admin profile** for the user you passed with `--email`, along with a full set +of demo leads, accounts, contacts, opportunities, cases, tasks, invoices and more. Re-running the +command reuses the existing `MicroPyramid` org and admin user rather than creating duplicates. + +You can also run migrations manually, the same way you'd run any other management command against +the container: + +```bash +docker compose exec backend python manage.py migrate +``` + +## Sign in + +With `DEBUG=True` (the default in `.env.docker`), the fastest way in is `devlogin` — a +management command that mints a JWT pair directly, without going through Google OAuth or email. +Full details, including how it resolves `--org` and what it requires of the target user, are in +[First sign-in](first-sign-in.md). For the org and user you just seeded: + +```bash +docker compose exec backend python manage.py devlogin you@example.com --org MicroPyramid +``` + +This prints an access token, a refresh token, and the `MicroPyramid` org's UUID, already bound +into the token — see [First sign-in](first-sign-in.md) for how to use them from the frontend at +`http://localhost:5173`. + +## What you got + +After `docker compose up --build` and one `seed_data` run, you have: + +- A PostgreSQL 16 database (`crm_db`) with RLS-enforcing policies applied by the migrations. +- A Django API on `http://localhost:8000`, running under the dev server (not a production WSGI + server — this compose file is for development). +- A Celery worker and a Celery beat scheduler, both connected to the `redis` service, running the + periodic and background tasks registered in `backend/crm/celery.py`. +- A SvelteKit frontend on `http://localhost:5173`, built against `PUBLIC_DJANGO_API_URL=http://localhost:8000`. +- A Django admin superuser, created automatically by `create_default_admin` on first boot, using + `ADMIN_EMAIL` / `ADMIN_PASSWORD` from `.env.docker` (`admin@localhost` / `admin` unless you + override them). This account can sign in to Django's own `/admin/` site; it does not have a CRM + organization profile, so it can't sign in to the CRM app itself unless you also give it a + profile via `seed_data` or the org-creation flow. +- If you ran `seed_data`, an org named `MicroPyramid` with an admin profile for the email you + passed, plus a large set of demo records. + +## Next steps + +- [First sign-in](first-sign-in.md) — the three ways to get a session, including the exact + `devlogin` invocation and what Google OAuth and magic links need. +- [Demo data and packs](demo-data.md) — what `seed_data` creates in detail, and how vertical packs + layer industry-specific pipelines and sample records on top of it. +- If Docker isn't the right fit, [Manual setup](manual-setup.md) covers running the backend, + database, Redis, Celery and frontend directly on your machine. diff --git a/docs/getting-started/first-sign-in.md b/docs/getting-started/first-sign-in.md new file mode 100644 index 00000000..74e866bf --- /dev/null +++ b/docs/getting-started/first-sign-in.md @@ -0,0 +1,141 @@ +# First sign-in + +BottleCRM issues sessions as JWT access/refresh pairs (`rest_framework_simplejwt`), not +server-side sessions or cookies set by a traditional username/password login endpoint — there is +no such endpoint. `backend/common/urls.py` registers exactly three ways to obtain a token pair. + +## The three sign-in paths + +| Path | Where it's implemented | Typical use | +|---|---|---| +| `devlogin` management command | `backend/common/management/commands/devlogin.py` | Local development only | +| Google OAuth | `GoogleOAuthCallbackView` (web, PKCE) and `GoogleIdTokenView` (mobile ID token), both in `backend/common/views/auth_views.py` | Self-hosted deployments with Google OAuth configured | +| Magic links (passwordless email) | `MagicLinkRequestView`, `MagicLinkVerifyView`, `MagicLinkVerifyCodeView`, all in `backend/common/views/auth_views.py` | Any deployment — needs only outbound email | + +All three ultimately mint tokens through the same `OrgAwareRefreshToken.for_user_and_org()` helper, +so the resulting access/refresh pair behaves identically regardless of which path produced it. + +## Local development: devlogin + +`devlogin` mints an access/refresh token pair for an existing user directly from the command +line, with no browser round-trip and no OAuth provider involved. It **refuses to run unless +`settings.DEBUG` is `True`** — the very first thing the command does is raise a `CommandError` if +`DEBUG` is falsy. That guard is also what makes the command safe to ship in the same codebase that +runs in production: `DEBUG=False` there, and the command becomes a no-op. + +Invocation: + +```bash +uv run python manage.py devlogin --org +``` + +Run from `backend/` (or, against a Docker stack, as +`docker compose exec backend python manage.py devlogin --org `). + +It prints three things: an **access token**, a **refresh token**, and — when `--org` is given — +the **org's UUID** it bound the token to. + +A few specifics worth knowing: + +- `--org` accepts either a name or a UUID, and resolves by **id first, then name**: it tries an + exact `Org.objects.get(id=org_arg)` lookup first, and only if that fails — not found, or the + string isn't a valid UUID at all (`Org.objects.get(id=...)` raises Django's `ValidationError`, + not `ValueError`, for a non-UUID string, so both are caught alongside `Org.DoesNotExist`) — + falls back to matching on `Org.name`. If more than one org shares that name, it refuses to guess + and asks you to pass an ID instead. +- The target **user must already have an active `Profile` in that org** — `devlogin` does not + create one. If it doesn't find one, it raises a `CommandError` naming the missing + user/org pair rather than silently proceeding without org context. (Passing `--create` will + create the *user* if it doesn't exist yet, but that's independent of the profile requirement — + a freshly created user still needs a `Profile` in the org before `--org` will succeed for them.) +- A token minted with `--org` **already carries the `org_id` claim**, so there's no follow-up + org-switch call needed — the frontend can use it immediately. Omit `--org` and you get a token + with no org bound; you'd then need to pick one interactively (see + [Choosing an organization](#choosing-an-organization) below) before the app has anything to show. + +### Using the tokens in the browser + +The snippet `devlogin` prints (`localStorage.setItem('access_token', ...)`, +`localStorage.setItem('refresh_token', ...)`) only writes `localStorage` — correct for +`frontend/src/lib/api.js`'s own `STORAGE_KEYS`, which is what the API client reads for direct, +client-side fetches, but **not enough on its own to sign you in through the SvelteKit app**. +`frontend/src/hooks.server.js` authenticates every server-rendered request from three **cookies** +instead — `event.cookies.get('jwt_access')`, `('jwt_refresh')` and `('org')` — and nothing +server-side reads `localStorage` at all. Without those cookies set, `hooks.server.js` treats the +request as signed out and redirects to `/login`, no matter what `localStorage` holds. + +To get past the guard, open the devtools console on `http://localhost:5173`, run the printed +`localStorage.setItem(...)` snippet, and set the matching cookies yourself: + +```js +const oneYear = 60 * 60 * 24 * 365; +document.cookie = `jwt_access=${ACCESS_TOKEN}; path=/; max-age=${oneYear}; samesite=lax`; +document.cookie = `jwt_refresh=${REFRESH_TOKEN}; path=/; max-age=${oneYear}; samesite=lax`; +document.cookie = `org=${ORG_UUID}; path=/; max-age=${oneYear}; samesite=lax`; +``` + +Substitute the printed access token, refresh token, and (if you passed `--org`) org UUID for +`ACCESS_TOKEN` / `REFRESH_TOKEN` / `ORG_UUID`; skip the `org` cookie entirely if you omitted +`--org`. Then reload. The root route `/` **is** the dashboard — +`frontend/src/routes/(app)/+page.svelte` sits directly at the route group's root — there is no +`/dashboard` route anywhere in the route tree. + +## Google OAuth + +Two endpoints handle Google sign-in, for two different clients: + +- **`POST /api/auth/google/callback/`** (`GoogleOAuthCallbackView`) — the web flow, used by the + SvelteKit frontend. It's a PKCE authorization-code exchange: the frontend sends `code`, + `code_verifier` and `redirect_uri`; the backend exchanges them with Google for tokens, decodes + the ID token payload, and requires `email_verified` to be true before it will create or sign in + a user — an unverified Google email is rejected outright. +- **`POST /api/auth/google/`** (`GoogleIdTokenView`) — the mobile flow. The client sends an + already-obtained Google ID token (`idToken`); the backend verifies it against Google + (`google.oauth2.id_token.verify_oauth2_token`) rather than doing a code exchange, and applies the + same `email_verified` check. + +Both paths get-or-create a `User` by email, reject sign-in for a deactivated account +(`user.is_active is False`), backfill the user's name from the Google profile if it wasn't set +yet, and mint an org-unbound token (no `--org` equivalent here — see +[Choosing an organization](#choosing-an-organization)). Google OAuth is only usable once +`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` are configured; in the Docker quick start's +`.env.docker` both are blank, which disables it. + +## Magic links + +Passwordless sign-in by email, in two deliveries: + +- **Link delivery** — `POST /api/auth/magic-link/request/` creates a token, emails a sign-in link, + and `POST /api/auth/magic-link/verify/` (`MagicLinkVerifyView`) exchanges the token in that link + for an access/refresh pair. +- **Code delivery** — the same request endpoint, with `delivery: "code"`, instead emails a 6-digit + one-time code. `POST /api/auth/magic-link/verify-code/` (`MagicLinkVerifyCodeView`) checks the + code (max 5 attempts before the code is burned) and returns tokens. This is the mobile-friendly + variant — no link-tap round-trip through a browser. + +The request endpoint always returns `200` with a generic "if this email is valid…" message, +whether or not the address has an account — this is deliberate, to avoid leaking which emails are +registered. Requests are rate-limited to 5 tokens per email per hour, and each token expires 10 +minutes after it's issued. Verifying creates the user if the email doesn't exist yet (same +deactivated-account check as the OAuth paths applies), and, like OAuth, mints an org-unbound +token unless the user already has an active profile in exactly one org — in that case the +response includes that org's tokens and a `current_org` field directly. + +In a self-hosted deployment, magic links require a working outbound email backend; in the Docker +quick start's dev configuration, email is printed to the container's stdout instead of actually +sent (a console backend), so you'd read the link or code from the `backend` container logs. + +## Choosing an organization + +A token minted without an org (any sign-in path except `devlogin --org`) still needs one before +the app has anything to show — every org-scoped view requires org context. The SvelteKit frontend +handles this at its `/org` route: it calls `GET /api/auth/me/` to list the organizations the +signed-in user has an active profile in, and lets them pick one. Selecting an org calls +`POST /api/auth/switch-org/` (`OrgSwitchView`) with the chosen `org_id`; the backend verifies the +user has an active `Profile` in that org and returns a fresh access/refresh pair with the org +claim bound in, the same shape `devlogin --org` produces directly. If the presented refresh token +is also sent, the backend blacklists it as part of the switch so the old, org-less token can't be +reused afterward. + +The same `/api/auth/switch-org/` endpoint is what a user with access to multiple organizations +uses later to move between them — it isn't only a first-sign-in step. diff --git a/docs/getting-started/manual-setup.md b/docs/getting-started/manual-setup.md new file mode 100644 index 00000000..54255729 --- /dev/null +++ b/docs/getting-started/manual-setup.md @@ -0,0 +1,171 @@ +# Manual setup + +Running the backend, database, Redis, Celery and frontend directly on your machine, without +Docker. Useful if you want faster iteration on backend code than a bind-mounted container gives +you, or if you're setting up on a host where Docker isn't available. + +## Prerequisites + +- **Python 3.12 or later** — `backend/pyproject.toml` declares `requires-python = ">=3.12"`, and + `backend/.python-version` pins `3.12`. +- **[uv](https://docs.astral.sh/uv/)** — the backend is managed with uv, not pip or a manually + created virtualenv. `uv sync` reads `backend/pyproject.toml` and `backend/uv.lock` and creates + a `.venv/` for you; every backend command in this guide runs via `uv run `, which resolves + that venv automatically. Install it once, system-wide: + + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + # or, on macOS: brew install uv + ``` +- **PostgreSQL** — the only supported database backend (`django.db.backends.postgresql` in + `backend/crm/settings.py`); Row-Level Security depends on it. +- **Redis** — used as the Celery broker and result backend. +- **Node.js and [pnpm](https://pnpm.io/)** — for the SvelteKit frontend in `frontend/`. + +## Backend + +From `backend/`: + +```bash +cd backend +uv sync +``` + +This creates `.venv/` and installs everything listed in `pyproject.toml`'s `dependencies` +(Django, DRF, Celery, `djangorestframework-simplejwt`, `weasyprint`, etc.) at the versions locked +in `uv.lock`. + +Copy the example environment file and adjust it for your local database and Redis: + +```bash +cp .env.example .env +``` + +`backend/crm/settings.py` loads this file via `python-dotenv` (`load_dotenv()` at import time), so +anything you put in `.env` is picked up the next time you run a management command. At minimum, +review `DBNAME`, `DBUSER`, `DBPASSWORD`, `DBHOST`, `DBPORT` against the database role you create in +the next section. + +Once `.env` points at a reachable database, apply migrations: + +```bash +uv run python manage.py migrate +``` + +And run the dev server: + +```bash +uv run python manage.py runserver +``` + +By default this serves on `http://127.0.0.1:8000`. + +## Database + +The Django `DATABASES` setting (`backend/crm/settings.py`) reads `DBNAME`, `DBUSER`, +`DBPASSWORD`, `DBHOST` and `DBPORT` from the environment, defaulting to a local `postgres` +superuser connection if they're unset. Don't leave it on that default: **the role Django connects +as must not be a PostgreSQL superuser**, or Row-Level Security is bypassed for every query that +role makes, regardless of what the RLS policies say. + +Create a dedicated, non-superuser role and database, for example: + +```bash +sudo -u postgres psql +``` + +```sql +CREATE DATABASE crm_db; +CREATE USER crm_user WITH PASSWORD 'crm_password'; +GRANT ALL PRIVILEGES ON DATABASE crm_db TO crm_user; +\connect crm_db; +GRANT ALL ON SCHEMA public TO crm_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO crm_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO crm_user; +``` + +Then point `.env` at it (`DBNAME=crm_db`, `DBUSER=crm_user`, `DBPASSWORD=crm_password`, +`DBHOST=localhost`, `DBPORT=5432`) and run `uv run python manage.py migrate`. + +You can confirm the role is configured correctly with the RLS management command: + +```bash +uv run python manage.py manage_rls --status +uv run python manage.py manage_rls --verify-user +``` + +## Redis and Celery + +Redis needs to be reachable at the URL in `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` (both +default to `redis://localhost:6379/0` in `.env.example`). Install it locally through your package +manager, or run the same image the Docker setup uses: + +```bash +docker run -p 6379:6379 redis:7-alpine +``` + +With Redis reachable, start a Celery worker from `backend/`: + +```bash +uv run celery -A crm worker --loglevel=INFO +``` + +`backend/crm/celery.py` also defines a set of periodic tasks (recurring-invoice generation, +overdue-invoice and expired-estimate checks, stale-opportunity and goal-milestone checks, SLA +breach scanning, notification and refresh-token cleanup) on cron-style schedules. To run those on +schedule you also need Celery beat, in a separate terminal: + +```bash +uv run celery -A crm beat --loglevel=INFO +``` + +Neither process is required for the API itself to respond to requests — they only matter for +background and scheduled work. + +## Frontend + +From `frontend/`: + +```bash +cd frontend +pnpm install +``` + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +At minimum, `PUBLIC_DJANGO_API_URL` should point at your backend (`http://localhost:8000` if +you're following the ports in this guide). Then start the dev server: + +```bash +pnpm run dev +``` + +This serves the SvelteKit app on `http://localhost:5173` (`vite dev` under the hood, per the +`dev` script in `frontend/package.json`). + +## Running all three + +A full local stack is four long-running processes across (at least) three terminals, all started +from the state above: + +```bash +# Terminal 1 — from backend/ +uv run python manage.py runserver + +# Terminal 2 — from backend/ +uv run celery -A crm worker --loglevel=INFO + +# Terminal 3 — from frontend/ +pnpm run dev +``` + +Redis and PostgreSQL need to already be running before any of these start (either installed +locally or run as standalone containers, as shown above). Add a fourth terminal for +`uv run celery -A crm beat --loglevel=INFO` from `backend/` if you need scheduled tasks to fire. + +Once all of this is up, see [First sign-in](first-sign-in.md) for how to get an authenticated +session, and [Demo data and packs](demo-data.md) for loading sample data. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..51248ca0 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,23 @@ +# BottleCRM + +BottleCRM is a multi-tenant CRM covering leads, accounts, contacts, opportunities, support +tickets (cases), tasks and invoices. + +## What BottleCRM is + +BottleCRM is MIT licensed and self-hosted: you run it on your own infrastructure, there are no +per-seat limits, and there is no feature paywall. + +## How it fits together + +Three clients — a SvelteKit web app, a Flutter mobile app, and the REST API — share one Django +REST Framework backend. Tenant isolation is enforced by PostgreSQL Row-Level Security keyed on +the `app.current_org` session variable, not by application code alone: application middleware +sets that variable from the authenticated user's JWT on every request, and the database enforces +it independently of the ORM's own org filters. + +## Where to start + +- **Operators**, deploying and running BottleCRM: [Docker quick start](getting-started/docker-quick-start.md) +- **Integrators**, building against the REST API: [Conventions](api/conventions.md) +- **Contributors**, working on the BottleCRM codebase: [Development setup](contributing/development-setup.md) diff --git a/docs/integrations/csv-import.md b/docs/integrations/csv-import.md new file mode 100644 index 00000000..56af0d37 --- /dev/null +++ b/docs/integrations/csv-import.md @@ -0,0 +1,179 @@ +# CSV import + +## What can be imported + +**There are two, unrelated ways to bulk-create records from a CSV, plus one gap.** Contacts and +cases (tickets) share a modern, two-phase preview/commit importer. Leads have a separate, older, +single-step bulk uploader that predates it and works nothing like it. Accounts have neither — +confirmed by grepping the denied concept, not just one filename, across the whole app: +`grep -rniE "\bupload\b|csv_import|import_views|ImportPreview|ImportCommit" --include="*.py" +backend/accounts/` (excluding migrations/tests) returns nothing. If you need to bulk-create +accounts, use `POST /api/accounts/` directly (see [Accounts](../api/accounts.md)) — the +[Python](python-sample.md) / [JavaScript](javascript-sample.md) samples on this site call the +equivalent lead endpoint, but the same obtain-token/list/create pattern applies unchanged. + +**The lead uploader:** `POST /api/leads/upload/` (`LeadUploadView`, +`backend/leads/views/lead_interactions.py`) takes `multipart/form-data` with the file under the +field name `leads_file` — not `file` like the importers below. It only requires a `title` column +(`LeadListForm`/`csv_doc_validate`, `backend/leads/forms.py`); every other column is optional and +free-form. The file is decoded as **`iso-8859-1`, not UTF-8** — unlike the contacts/cases +importers below, so a UTF-8 file with multi-byte characters (accented names, curly quotes) won't be +rejected, it will be silently mis-decoded. There's no preview step: a valid upload returns +`{"error": false, "message": "Leads created Successfully"}` immediately and hands the parsed rows +to a Celery task (`create_lead_from_file`, `backend/leads/tasks.py`) that creates the `Lead` rows +in the background — by the time you get the `200`, nothing has necessarily been written yet, and +there's no per-row error report or count. Rows with a blank `title` are dropped before the task +even runs, and the task itself additionally expects every row to have an `email` value it can +match against a basic format check; rows that fail that check are silently skipped, and a file +with no `email` column in it at all fails the whole background job outright, not just the rows +missing one, because the check isn't guarded. A file that has no `title` column, isn't valid CSV, +or where every row is invalid is rejected synchronously with `{"error": true, "errors": {...}}`. + +**Permission gating is not the same across the three surfaces, and this is worth knowing before +you rely on it.** `LeadUploadView.permission_classes = (IsAuthenticated, HasOrgContext)` — no +admin check, no `has_sales_access` check. **Any authenticated org member can bulk-create leads** +through this endpoint. The contacts and cases importers below both gate on `_can_import` +(admin or `has_sales_access`) — a deliberately narrower surface than a single `POST` to their +respective list endpoints. The lead uploader has no equivalent restriction. + +Both importers below share the same design: a stateless two-phase flow (`parse_and_validate` then +`commit_rows`, `backend/contacts/services/csv_import.py` and `backend/cases/services/csv_import.py`), +a 5 MB / 5,000-row cap, UTF-8-only decoding (with or without a BOM — anything else returns a +`header_error` asking you to re-save the file, rather than silently producing mojibake that then +passes validation), and gating to admins or users with sales access: + +```python +def _can_import(profile) -> bool: + if profile is None: + return False + if getattr(profile, "role", None) == "ADMIN": + return True + if getattr(profile, "is_admin", False): + return True + return bool(getattr(profile, "has_sales_access", False)) +``` + +Anyone else gets `403 {"error": true, "message": "Permission denied"}` from either endpoint of +either importer — this mass-create surface is deliberately not open to every org member the way a +single `POST` is. + +## Contacts + +`POST /api/contacts/import/preview/` and `POST /api/contacts/import/commit/` +(`ContactImportPreviewView` / `ContactImportCommitView`, `backend/contacts/import_views.py`), both +`multipart/form-data` with the file under the field name `file`. + +Required headers: `first_name`, `last_name`. Optional: `email`, `phone`, `organization`, `title`, +`department`, `do_not_call`, `linkedin_url`, `address_line`, `city`, `state`, `postcode`, +`country`, `description`, `account_name`, `assigned_emails`, `team_names`, `tags`. Any header not +in that list — including a misspelling — fails the whole file with a `header_error` before any row +is read; there's no partial-header tolerance. + +`assigned_emails`, `team_names` and `tags` accept multiple values separated by `;` in one cell. +`account_name`, `assigned_emails` and `team_names` must resolve to an existing account, active +profile or team in your org — an unresolved reference is a row error, not a silent skip or an +auto-create (tags are the one exception: an unrecognized tag name is auto-created at commit). +`country`, if present, must be one of the codes documented on the [Leads](../api/leads.md#fields) +page's `country` field (same `COUNTRIES` list, shared across the codebase). + +## Tickets + +`POST /api/cases/import/preview/` and `POST /api/cases/import/commit/` +(`CaseImportPreviewView` / `CaseImportCommitView`, `backend/cases/import_views.py`), same +`multipart/form-data` shape. + +Required headers: `name`, `status`, `priority`. Optional: `description`, `case_type`, +`account_name`, `contact_emails`, `assigned_emails`, `team_names`, `tags`, `closed_on`. +`status` and `priority` are matched case-insensitively against the same `STATUS_CHOICE` / +`PRIORITY_CHOICE` enums the API uses (`New`, `Assigned`, `Pending`, `Closed`, `Rejected`, +`Duplicate`; `Low`, `Normal`, `High`, `Urgent`) — a value outside those is a row error, not a +free-text fallback. `case_type`, if present, must be one of `Question`, `Incident`, `Problem`. +`closed_on` must be `YYYY-MM-DD`. `contact_emails` (plural, `;`-separated) must resolve to +existing contacts in your org — this importer does not auto-create contacts the way inbound email +does (see [Inbound email](inbound-email.md#how-inbound-email-becomes-a-ticket)). + +## Matching + +Every reference field — `account_name`, `contact_emails`/`assigned_emails`/`team_names`, and each +importer's own duplicate check — is resolved against your org only. Both importers bulk-prefetch +every distinct reference value in the file once (one query per reference type, not one per row), so +a 5,000-row file with several reference columns runs on the order of ten queries during validation, +not tens of thousands — and, just as importantly, the lookups can't reach across tenants: a CSV +that names another org's account or a team that doesn't exist in your org fails to resolve the same +way a typo would, because the prefetch never looks outside `org=`. + +Duplicate detection differs by importer, because "duplicate" means something different for a +person than for a ticket: + +- **Contacts**: `email` is a hard error against both the file (another row with the same address) + and the org's existing contacts — the database enforces a per-org, case-insensitive unique + constraint, and the importer mirrors it so the failure surfaces as a row error instead of an + `IntegrityError` at commit. `phone` is a hard error too, normalized with the same digits-only, + last-10-characters comparison the rest of the codebase uses (`common.validators.normalize_phone`), + even though there's no database constraint backing it. A full-name collision (`first_name` + + `last_name`) is **only** an error when the row has neither an email nor a phone to disambiguate + it from an existing same-named contact — two people can legitimately share a name, so the check + only fires when there's nothing else to tell them apart. +- **Tickets**: `name` is a hard error against both the file and the org's existing case names — + there is no email/phone-style disambiguation exception, because a ticket name isn't expected to + double as a person's identity the way a contact's is. + +## Response shape + +Both endpoints of both importers return the same shape. `preview` never writes to the database — +it re-runs the identical validation `commit` does and returns: + +```json +{ + "header_error": null, + "valid": [{"row": 2, "first_name": "Ada", "last_name": "Lovelace", "...": "..."}], + "errors": [{"row": 3, "field": "email", "message": "'not-an-email' is not a valid email"}], + "summary": {"total": 2, "valid": 1, "invalid": 1} +} +``` + +`row` is 1-based against the data rows (the header is not row 1, so the first data row is `row: 1`). +`header_error` is non-null instead of `valid`/`errors` for a whole-file problem — a missing +required header, an unrecognized header, non-UTF-8 content, an empty file, or more than 5,000 data +rows — and when it's set, `valid` and `errors` are always empty; there's no partial parse of a file +that fails at the header stage. + +`commit` re-parses and re-validates the file from scratch — it does not trust a client-side +"these rows already passed preview" claim — and refuses to write anything if *any* row is invalid: + +```json +{"error": false, "created": 42, "ids": ["", "..."]} +``` + +```json +{ + "error": true, + "message": "Fix the invalid rows before importing", + "errors": [{"row": 3, "field": "email", "message": "..."}], + "created": 0 +} +``` + +A `header_error` at commit time returns the same field, `created: 0`, and no `errors` array. + +The two importers handle one edge case differently: a conflicting row created by someone else +between your preview and your commit call, most plausibly the same email landing twice from two +concurrent contact imports. The **contacts** importer's `commit_rows` wraps the write in a +`try`/`except IntegrityError` and turns that race into a clean response instead of a `500`: + +```json +{ + "error": true, + "message": "A contact was created concurrently that conflicts with this import (likely a duplicate email). Re-run preview and try again.", + "detail": "...", + "created": 0 +} +``` + +The **cases (tickets)** importer's `commit_rows` has no equivalent `try`/`except` — because it +doesn't need one the same way: `Case` has no database-level uniqueness constraint on `name` (unlike +`Contact.email`, which the database itself enforces per org), so the same race there wouldn't raise +an `IntegrityError` at all. It would instead create two same-named cases, since the importer's +"duplicate name" check only ever looked at what existed *at validation time*. This is a narrow +window — you'd need two concurrent commits importing the same ticket name at once — but it's worth +knowing the two importers aren't symmetric here. diff --git a/docs/integrations/inbound-email.md b/docs/integrations/inbound-email.md new file mode 100644 index 00000000..4d9113cc --- /dev/null +++ b/docs/integrations/inbound-email.md @@ -0,0 +1,157 @@ +# Inbound email + +BottleCRM can turn email sent to a support address into a case (support ticket), and thread +replies onto the same case automatically. This is the **only** webhook in the product, and it runs +in one direction only: BottleCRM *receives* mail through it. Nothing in this codebase posts a +webhook out to a URL you configure — there is no equivalent "notify my system when a case closes" +hook. If you need BottleCRM to push events elsewhere, you're integrating through the +[MCP server](mcp-server.md) or polling the REST API; nothing here does that for you. + +## How inbound email becomes a ticket + +The pipeline (`backend/cases/inbound/pipeline.py`, entry point `ingest()`) runs the same four +steps for every message that reaches a configured mailbox: + +1. **Spam/noise filtering** (`cases/inbound/spam.py`). A message is dropped — recorded, but no + case touched — if it looks like a bounce, carries `Auto-Submitted: `, + `Precedence: bulk|list|junk`, `X-Autoreply: yes`, or both `List-Id` and `List-Unsubscribe` + headers (a mailing-list signature; either header alone isn't enough, since transactional mail + sometimes carries `List-Unsubscribe` on its own). A message with no `Message-ID` at all is also + treated as a drop — without one there's nothing to thread or de-duplicate on. +2. **Threading** (`cases/inbound/threading.py`, `find_existing_case`). `In-Reply-To` and every id + in `References` are merged into one de-duplicated candidate list, and a **single** query + matches that whole list against prior messages' `Message-ID`s, ordered by most-recently-received + — so if more than one candidate id matches, the most recently received message wins; it is not + "`In-Reply-To` first, then `References`" as two separate lookups. Only after that does the match + fall through to a case's own thread id (`external_thread_id`, including one inherited from a case + merged into it, via `alt_thread_ids`) and, as a last resort, a `[Case #<8-hex-chars>]` prefix + surviving in the subject line. If the matched case was since merged into another, the reply is + attached to the surviving primary instead. +3. **Contact resolution** (`cases/inbound/contacts.py`). The `From:` address is matched + case-insensitively against existing contacts in the mailbox's org; if none matches, a new + `Contact` is auto-created from the display name (or the email's local part, if there's no + display name) and linked to the case. +4. **Case creation or update.** No match in step 2 creates a new `Case` — subject becomes the name + (truncated to 64 characters), body becomes the description, and **`status` is always `"New"`** + (`pipeline.py`: `status="New"` is hardcoded — the mailbox has no `default_status` field, so + don't go looking for one). `priority` and `case_type` do come from the mailbox's + `default_priority`/`default_case_type`, and if the mailbox has a `default_assignee`, that + profile is added to the new case's `assigned_to`. A match reuses the existing case, attaches the + contact if not already linked, and can reopen a closed case (subject to the org's reopen policy) + if the reply arrives inside the configured reopen window. + +Every message — including dropped ones — is recorded as exactly one `EmailMessage` row +(`org` + `message_id` is the idempotency key, so a provider retry never creates a duplicate case). +That gives admins a forensic trail even for the mail that never became a ticket, and it's what +[the mailbox list's per-mailbox counts](#configuring-a-mailbox) are computed from. + +## Configuring a mailbox + +`InboundMailbox` (`backend/cases/models.py`) is a per-org row managed at +`GET`/`POST /api/cases/mailboxes/` and `GET`/`PUT`/`DELETE /api/cases/mailboxes/{id}/` +(`InboundMailboxListCreateView` / `InboundMailboxDetailView`, +`backend/cases/inbound_views.py`), admin-only for every write (`POST`, `PUT`, `DELETE` all check +`role == "ADMIN"` or `is_admin`; a non-admin gets `403`). Reads are open to any org member. + +The model declares four provider choices — `ses`, `mailgun`, `postmark`, `imap` — but **only `ses` +is actually implemented today**. The webhook itself checks this: any mailbox whose `provider` is +not `"ses"` returns `501 Not Implemented` for every notification it receives +(`InboundMailboxWebhookView.post`, `backend/cases/inbound_views.py`). The `imap_host` / +`imap_port` / `imap_username` / `imap_password_enc` fields exist on the model already so that a +future IMAP implementation doesn't need a schema migration, but nothing reads them yet — setting +`provider` to anything but `ses` configures a mailbox that will reject every message sent to it. + +Fields worth knowing when creating one: + +| Field | Notes | +| --- | --- | +| `address` | The inbound email address. On **create**, `validate_address` pre-checks for a case-insensitive duplicate in your org and returns a clean `400` if one exists (`self.instance is None` gates this — it's explicitly a create-only check). On **update** (`PUT`), that pre-check does not run at all: renaming a mailbox's `address` onto one already used elsewhere in your org skips the serializer check entirely and goes straight to `.save()`, where the database's own `UniqueConstraint` on `(org, address)` rejects it — deterministically, on every such `PUT`, not just under a race, and with no `try`/`except` around the write to turn it into a clean error. Expect an unhandled `IntegrityError` (`500`), not a `400`, if you rename a mailbox onto an address your org already has. | +| `provider` | Must be `ses` for the mailbox to accept mail (see above). | +| `webhook_secret` | Auto-generated (`secrets.token_urlsafe(32)`) on create if you don't supply one. See [Securing the endpoint](#securing-the-endpoint) for what it does and does not do. | +| `topic_arn` | Leave blank on create — it's pinned automatically from the first verified SNS `SubscriptionConfirmation`, not set by hand. See below. | +| `default_priority`, `default_case_type` | Set directly on any new case this mailbox creates; both optional. There is no `default_status` — a new case's `status` is always `"New"`, not configurable per mailbox (see [above](#how-inbound-email-becomes-a-ticket)). | +| `default_assignee_id` | Optional. If set, that profile is added to the new case's `assigned_to` (a many-to-many "add", not a status or ownership field of its own). | +| `is_active` | An inactive mailbox's webhook returns `404` for every notification, same as a mailbox id that doesn't exist — set this to disable a mailbox without deleting its history. | + +`GET /api/cases/mailboxes/` adds two per-mailbox fields on top of the serializer above — +`cases_last_30d` and `last_received_at` — plus an org-wide `totals` object +(`{"count", "active", "cases_last_30d"}`), all computed by `_mailbox_analytics` +(`backend/cases/inbound_views.py`) from the `EmailMessage.mailbox` foreign key, not stored on the +mailbox row itself. `cases_last_30d` counts distinct cases *created* in the last 30 days that have +an inbound message through that mailbox (a reply to an older case doesn't count as a new ticket); +`last_received_at` is the newest inbound message's `received_at`, including dropped ones — the +address still received mail even if the pipeline discarded it. Neither field is present on +`GET /api/cases/mailboxes/{id}/`, which returns only the plain serializer. + +## The SNS webhook + +Point an AWS SES receipt rule (via an SNS topic, "SNS Notification with full content" or the +older JSON-envelope form — the parser handles both) at: + +``` +POST /api/cases/inbound// +``` + +This route is deliberately public — `authentication_classes = ()`, `permission_classes = +(AllowAny,)` — because SNS has no way to send your BottleCRM credentials. The `` in the +URL is what scopes each notification to one org: the view looks the mailbox up by that id (and +`is_active=True`) before doing anything else, and because the webhook bypasses +`RLSContextMiddleware` entirely (there's no authenticated session to derive an org from), the view +sets the RLS session variable by hand from `mailbox.org_id` before touching any other org-scoped +table. + +On a `Notification`, the view acks with: + +```json +{"ok": true, "case_id": "", "dropped": false, "reason": "", "created_case": true} +``` + +`dropped`/`reason` reflect the spam-filtering outcome from [the pipeline](#how-inbound-email-becomes-a-ticket) +above; the response is still `200` even when the message was dropped, and deliberately so — SES +already accepted the message from the original sender, and returning a `4xx` here would just +trigger pointless provider retries of a message that was correctly classified as noise, not lost. + +## Securing the endpoint + +Because the endpoint takes no credential, trust is established entirely by verifying the message +itself, in two layers — and it takes both: + +1. **SNS signature verification** (`cases/inbound/sns.py`, `verify_sns_message`). The payload's + `Signature` is checked against the certificate at its `SigningCertURL`, which is pinned to the + `sns..amazonaws.com` host family, fetched with redirects disabled and TLS verified + against the system CA store, and itself checked for a matching Common Name and a currently-valid + validity window before its public key is trusted. This proves the payload was genuinely signed + by *some* SNS topic in *some* AWS account — nothing more. +2. **TopicArn pinning.** A valid SNS signature alone isn't enough, because anyone with an AWS + account can create a topic and have SNS sign messages for it. Each mailbox is pinned to the + exact `TopicArn` it should accept: the pin is captured automatically, trust-on-first-use, from + the first signature-verified `SubscriptionConfirmation` a mailbox receives, and every + `Notification` after that must carry the exact same `TopicArn` or it's rejected. A mailbox with + no pin yet rejects every `Notification` outright (only a `SubscriptionConfirmation` can + establish the pin), and once pinned, a mailbox does not silently re-pin to a different topic — + without this, anyone who learned a mailbox's UUID could point their own SNS topic at it and have + AWS legitimately sign forged mail on their behalf, which — because the pipeline threads replies + onto existing cases — means injecting messages into a live customer conversation, not just + spam tickets. + +Both failure modes return the same generic `403` (`"Signature verification failed"`) rather than +distinguishing "bad signature" from "wrong topic" — a caller who gets past the existence check +below can't tell which of the two rejected them, or what the pinned `TopicArn` is. That existence +check is a separate, earlier step: a `mailbox_id` that doesn't match any active mailbox returns +`404` before either check above ever runs, which does let a caller distinguish "this id belongs to +no mailbox" from "this id belongs to a mailbox, but the request failed verification" (a `403`, a +`501` for an unsupported provider, and so on) — a real, if narrow, way to enumerate valid mailbox +ids, notwithstanding the source comment's stated intent not to leak that. Mailbox ids are UUIDs, +not sequential, so this isn't practically brute-forceable, but it's not the airtight non-disclosure +the comment claims either. + +**`webhook_secret` is not part of this verification.** It is stored on the mailbox, auto-generated +on create, and settable/rotatable by an admin through the mailbox CRUD endpoints above, but nothing +in `InboundMailboxWebhookView.post` reads or compares it — the two checks above are what actually +gate the endpoint. What `webhook_secret` (and `topic_arn`, held to the same bar) does get is +**stripped from every API response to a non-admin caller**: `InboundMailboxSerializer.to_representation` +pops both fields unless the requesting profile is an admin, so a regular org member who can list +mailboxes never sees either value. Treat both as admin-only secrets when deciding who in your org +should have access to the CRM at all — not because a leaked `webhook_secret` lets someone forge +mail today, but because a leaked `topic_arn` embeds your AWS account id, and because the field is +positioned to matter if a future provider integration does check it. diff --git a/docs/integrations/javascript-sample.md b/docs/integrations/javascript-sample.md new file mode 100644 index 00000000..06f729b5 --- /dev/null +++ b/docs/integrations/javascript-sample.md @@ -0,0 +1,172 @@ +# JavaScript sample + +## What this does + +The same flow as the [Python sample](python-sample.md), in JavaScript: obtain an access token, +list the org's leads, and create one — using only the built-in `fetch`, no npm dependencies. It's +written for Node (tested for compatibility with Node 24), but the `fetch` calls themselves would +run unmodified in any modern browser's console against a CORS-enabled backend. + +As with the Python sample, "obtains a token" means reading a personal access token (`bcrm_pat_…`) +from the environment rather than driving an interactive sign-in — see +[Tokens and API keys](../api/tokens-and-api-keys.md#personal-access-tokens) for what a PAT +authenticates as, and the [Python sample](python-sample.md#what-this-does) for how to mint one +locally. + +## The script + +```javascript +#!/usr/bin/env node +// List an org's leads and create one, using a BottleCRM personal access token. +// +// Usage: +// BCRM_BASE_URL=http://localhost:8000 BCRM_TOKEN=bcrm_pat_... node leads-sample.mjs +// +// Requires Node 18+ for the built-in `fetch`. The flag history, precisely: +// --experimental-fetch was a Node 17.5 flag; Node 18 made fetch available +// with no flag at all (still labeled experimental, with a startup warning); +// Node 21 dropped that warning and made fetch stable. Tested for Node 24. +// No npm dependencies. +// +// See docs/api/tokens-and-api-keys.md for how to mint BCRM_TOKEN, and +// docs/api/leads.md for the shape of the /api/leads/ responses this reads. + +const BASE_URL = (process.env.BCRM_BASE_URL || "http://localhost:8000").replace(/\/$/, ""); +const TOKEN = process.env.BCRM_TOKEN; + +if (!TOKEN) { + console.error( + "Set BCRM_TOKEN to a bcrm_pat_... personal access token " + + "(see docs/api/tokens-and-api-keys.md)." + ); + process.exit(1); +} + +const headers = { + Authorization: `Bearer ${TOKEN}`, + Accept: "application/json", + "Content-Type": "application/json", +}; + +// GET /api/leads/ — the response splits leads into open/closed sections +// rather than one flat list; see docs/api/leads.md#list-leads. +async function listLeads() { + const res = await fetch(`${BASE_URL}/api/leads/`, { headers }); + const data = await res.json(); + if (!res.ok) { + throw new Error(`List failed (${res.status}): ${JSON.stringify(data)}`); + } + const openSection = data.open_leads; + const leads = openSection.open_leads; + console.log( + `${leads.length} open lead(s) on this page (of ${openSection.leads_count} total open):` + ); + for (const lead of leads) { + const name = `${lead.first_name ?? ""} ${lead.last_name ?? ""}`.trim(); + console.log( + ` - ${name || "(no name)"} <${lead.email ?? "no email"}> [${lead.status}]` + ); + } + return data; +} + +// POST /api/leads/ — a 200 with {"error": false, ...} is success; the +// response does not echo the created record, so we look it up afterward. +async function createLead() { + const email = `sample.lead+${Date.now()}@example.com`; + const res = await fetch(`${BASE_URL}/api/leads/`, { + method: "POST", + headers, + body: JSON.stringify({ + first_name: "Sample", + last_name: "Lead", + email, + status: "assigned", + source: "other", + }), + }); + const body = await res.json(); + if (!res.ok || body.error) { + throw new Error(`Create failed (${res.status}): ${JSON.stringify(body)}`); + } + console.log(`Create response: ${JSON.stringify(body)}`); + return email; +} + +// GET /api/leads/?email=... — `email` is a case-insensitive contains filter +// (docs/api/conventions.md#filtering-and-search), fine here since the address +// we just created is unique in this org. +async function findByEmail(email) { + const url = new URL(`${BASE_URL}/api/leads/`); + url.searchParams.set("email", email); + const res = await fetch(url, { headers }); + const data = await res.json(); + if (!res.ok) { + throw new Error(`Lookup failed (${res.status}): ${JSON.stringify(data)}`); + } + const matches = data.open_leads.open_leads; + if (matches.length) { + console.log(`Found the new lead: id=${matches[0].id}`); + } else { + console.log("New lead not on this page (increase limit/offset to find it)."); + } +} + +async function main() { + console.log("Listing existing leads..."); + await listLeads(); + console.log("\nCreating a lead..."); + const email = await createLead(); + console.log("\nConfirming it was created..."); + await findByEmail(email); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); +``` + +## Running it + +Save the script as `leads-sample.mjs`. It uses no `import`/`export` and no top-level `await` (the +`await` calls are all inside `async function main()`, invoked with `.catch()` rather than awaited +at the top level), so it runs the same way whether Node treats the file as CommonJS or an ES +module — the `.mjs` extension just removes any ambiguity from a `package.json` in the same +directory. You need a reachable +BottleCRM backend and a personal access token, minted as described in the +[Python sample](python-sample.md#what-this-does): + +```bash +BCRM_BASE_URL=http://localhost:8000 \ +BCRM_TOKEN=bcrm_pat_... \ + node leads-sample.mjs +``` + +**A note on verification:** like the Python sample, this script was checked by static means — +every URL and field against [Leads](../api/leads.md) and [Conventions](../api/conventions.md), and +the whole file with `node --check` — rather than executed against a live database, to avoid +writing a row into a shared instance this documentation didn't provision. The example output is +illustrative, not a captured run, and is identical in shape to the +[Python sample's](python-sample.md#running-it) since both call the same endpoints: + +```text +Listing existing leads... +3 open lead(s) on this page (of 3 total open): + - Jordan Blake [assigned] + - Priya Shah [in process] + - (no name) [assigned] + +Creating a lead... +Create response: {"error":false,"message":"Lead Created Successfully"} + +Confirming it was created... +Found the new lead: id=3f2a1c9e-.... +``` + +A validation failure comes back as a `400` and the script throws with the error body BottleCRM +returned, per [Errors](../api/errors.md#validation-errors): + +```text +Create failed (400): {"error":true,"errors":{"email":["Another lead in this organisation already uses that email address."]}} +``` diff --git a/docs/integrations/mcp-server.md b/docs/integrations/mcp-server.md new file mode 100644 index 00000000..3d64de44 --- /dev/null +++ b/docs/integrations/mcp-server.md @@ -0,0 +1,204 @@ +# MCP server + +`bcrm-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI +agent (Claude Desktop, Cursor, Codex CLI, Gemini CLI, or any other MCP client) read and write CRM +data. It lives in its own top-level package, `mcp_server/`, next to `backend/` and `frontend/` — +not inside the Django project — and it is a thin HTTP client of the CRM's own REST API: it has no +database connection of its own and does not import Django (`mcp_server/src/bcrm_mcp/client.py` +depends only on `httpx`). Every tool call becomes one authenticated request to `/api/...`, and the +backend enforces RLS, RBAC and field validation exactly as it would for any other caller. + +Source and further detail: [`mcp_server/README.md`](https://github.com/django-crm/Django-CRM/blob/master/mcp_server/README.md) +in the repository. + +## What it does + +The server exposes eight tools an agent can call — search/list, get, create, update, delete, a +generic non-CRUD action runner, a schema-describe helper, and an action-discovery helper — over +eight CRM entities: leads, contacts, accounts, opportunities, tasks, cases, invoices and +solutions. It does not add any capability the REST API doesn't already have; it's a uniform, +agent-friendly surface over the same endpoints documented in [API reference](../api/conventions.md). + +## Installing + +There are two things you can install, depending on how you want to run it. + +**The standalone package**, for running the server as a local subprocess your MCP client launches +(the `stdio` transport, the default): + +```bash +cd mcp_server +uv sync +``` + +`uv sync` creates `mcp_server/.venv/` from `mcp_server/pyproject.toml` + `uv.lock`. The package +requires Python 3.11+ and pulls in `fastmcp`, `httpx` and `pydantic` — nothing from the Django +project. + +**The `mcp` extra on the backend**, for mounting the server inside the Django app so it's reachable +over HTTP without a local install at all: + +```bash +cd backend +uv sync --extra mcp +``` + +This is declared in `backend/pyproject.toml` as `[project.optional-dependencies] mcp = +["bcrm-mcp"]`, with `bcrm-mcp` resolved via `[tool.uv.sources]` as an editable path dependency on +`../mcp_server`. It is opt-in specifically so the core backend install stays lean — a plain `uv +sync` in `backend/` never pulls in `fastmcp` or `starlette` at all. If the extra isn't installed, +`backend/crm/asgi.py` catches the resulting `ImportError` and serves Django alone; an incomplete or +skipped MCP install never takes the rest of the site down. + +## Hosted at /mcp + +`backend/crm/asgi.py` optionally mounts the MCP server at exactly `/mcp` on the same process that +serves the rest of the API — no separate deployment. This only activates when three things are all +true: the `mcp` extra is installed, `BCRM_MCP_ENABLED` is not set to a falsy value (`0`, `false`, +`no` or `off` — anything else, including unset, leaves it enabled), and the app is served under +**ASGI**, not WSGI: + +```bash +# from backend/ +uv sync --extra mcp +BCRM_BASE_URL=http://127.0.0.1:8000 \ + uv run uvicorn crm.asgi:application --host 0.0.0.0 --port 8000 +# MCP is now live at http://:8000/mcp +``` + +`manage.py runserver` uses WSGI, so the mount is inactive under the ordinary local dev server — +this is purely an ASGI-server concern and doesn't change any other local workflow. +`BCRM_BASE_URL` is the loopback address the mounted tools call to reach the CRM API; it defaults +to `http://127.0.0.1:8000`, which is correct when MCP and Django share the same process, as they +do here. + +The mount dispatches by path prefix rather than Starlette's `Mount` (which only matches +`/mcp/…` with a trailing segment and would let a bare `/mcp` fall through to Django) — both `/mcp` +and `/mcp/...` are routed to the MCP app, everything else goes to Django as before. + +## Authenticating + +The server has two transports, and they authenticate differently: + +- **`stdio`** (default, used when your MCP client launches `bcrm-mcp` as a local subprocess) — the + whole process acts as one user. It reads a single token from `BCRM_TOKEN` at startup and reuses + one `CrmClient` for every call. `Settings.from_env()` refuses to start without it. +- **`http`** (the `/mcp` mount above, or a standalone server run with `BCRM_TRANSPORT=http`) — a + long-lived, multi-user server. There is no server-side token at all; `Settings.from_env()` + actively rejects a `BCRM_TOKEN` set alongside `BCRM_TRANSPORT=http`, because a shared token would + collapse every caller into one identity. Instead **every request carries its own + `Authorization: Bearer ` header**, and `ClientResolver.get()` + (`mcp_server/src/bcrm_mcp/server.py`) builds a fresh, uncached `CrmClient` from that header on + every single call — never reused across requests, so one caller's identity can never leak into + the next caller's response. + +For the `/mcp` mount specifically, `crm/asgi.py` enforces this at the edge: any request to `/mcp` +or `/mcp/...` with no well-formed bearer token gets a `401` before it reaches the MCP layer at +all — even `initialize` and tool listing are unreachable without a token, not just the tool calls +that touch data. The token's actual validity (not expired, not revoked, owning profile and org +still active) is then re-checked by the backend on every API call the tool makes, the same way any +other PAT-authenticated request is checked — see +[Tokens and API keys](../api/tokens-and-api-keys.md#personal-access-tokens) for how a PAT is +minted, validated and revoked. + +In both transports, the token is a normal BottleCRM personal access token +(`bcrm_pat_…`) — there is nothing MCP-specific about it. + +## Available tools + +| Tool | Kind | Description | +| --- | --- | --- | +| `crm_search` | read-only | List/search records of an entity. Takes `query`, `filters`, `limit`, `offset`. `limit` is capped at **50** regardless of what's requested (`MAX_LIMIT` in `mcp_server/src/bcrm_mcp/tools.py`). | +| `crm_get` | read-only | Fetch one record's full detail by `id`. | +| `crm_create` | write | Create a record from a `data` object, validated server-side exactly as `POST` to the underlying endpoint would be. | +| `crm_update` | write | Partially update a record (PATCH semantics) from a `data` object. **Returns an error for `invoices`** — see below. | +| `crm_delete` | destructive | Delete a record. Refuses to run unless called with `confirm=true`. **Also returns an error for `invoices`** — see below. | +| `crm_action` | write | `POST` a non-CRUD action to `/api////`. Most of the actions `entities.py` lists don't resolve to a real route today — see below before relying on this. | +| `crm_describe` | read-only | Return an entity's fields, types, enums and which are required, derived from the live OpenAPI schema at `/schema/`. | +| `list_actions` | read-only | Return the allowed non-CRUD actions for every entity, exactly as `entities.py` declares them — see below for which of those are actually reachable. | + +CRUD tools take an `entity` argument. `resolve_path` (`mcp_server/src/bcrm_mcp/entities.py`) maps +each to its real API path and the non-CRUD actions `entities.py` declares as allowed for it: + +| Entity | API path | Non-CRUD actions declared | +| --- | --- | --- | +| `leads` | `/api/leads/` | `convert`, `add_comment` | +| `contacts` | `/api/contacts/` | `add_comment` | +| `accounts` | `/api/accounts/` | `add_comment` | +| `opportunities` | `/api/opportunities/` | `add_comment` | +| `tasks` | `/api/tasks/` | — | +| `cases` | `/api/cases/` | `add_comment` | +| `invoices` | `/api/invoices/` | `send` | +| `solutions` | `/api/cases/solutions/` | — | + +Solutions are served under the cases app, not at a top-level `/api/solutions/` — `crm_search(entity="solutions", ...)` resolves to `/api/cases/solutions/` correctly, but it's worth knowing if you're +cross-referencing the [Endpoint index](../api/endpoint-index.md). + +**Most of the actions in that table don't work, and neither does `crm_update`/`crm_delete` on +invoices — this is a real bug in `entities.py`, not a doc gap, and it isn't fixed here.** +`crm_action` builds `POST /api////` (`tools.py`). Checked against the real +Django URLconfs (`leads/urls.py`, `contacts/urls.py`, `accounts/urls.py`, `opportunity/urls.py`, +`cases/urls.py`, `invoices/api_urls.py`): + +- **`convert` on `leads`** — no such route exists anywhere in `leads/urls.py`. Converting a lead + is done by setting its status, not by calling an action: + `crm_update(entity="leads", id=..., data={"status": "converted"})`. +- **`add_comment`** on `leads`, `contacts`, `accounts`, `opportunities` and `cases` — none of these + resolve either. Every one of those apps routes comments at `comment//` + (e.g. `POST /api/leads/comment//`), not `/add_comment/`, and Django's `` URL + converter doesn't match a `/` inside the segment, so `/add_comment/` matches nothing. No tool + in this MCP server currently reaches the `comment//` routes at all. +- **`send` on `invoices`** is the only one that actually resolves — + `POST /api/invoices//send/` is a real route (`invoices/api_urls.py`). + +Calling `crm_action` with any of the non-working entries returns a `CrmError` carrying the +backend's `404` — the MCP layer doesn't pre-validate that the action exists before posting. + +Separately, `InvoiceDetailView` (`backend/invoices/api_views.py`) implements only `get` and `put` +— no `patch`, no `delete` — so `crm_update` (which sends `PATCH`) and `crm_delete` (which sends +`DELETE`) both come back as a `CrmError` carrying a `405` for `invoices`, regardless of the +`data`/`confirm` you pass. To change an invoice, use `crm_action(entity="invoices", +action="send", confirm=true)` for sending it, or a full `PUT` via the REST API directly (see +[Invoices and estimates](../api/invoices.md)) for anything else — there is no MCP tool that +partially updates or deletes an invoice today. + +## Permissions + +**The agent is exactly as privileged as the PAT it's given, no more and no less.** `PATAuthentication` +resolves the token to the owning `Profile` and sets `request.profile`/`request.org` from it +(`backend/common/pat_auth.py`) — the same code path any other PAT-authenticated request goes +through. There is no separate MCP identity, no elevated service account, and no way for the MCP +layer itself to grant a permission the token's owner doesn't already have. A PAT does carry a +`scopes` field, but nothing in the request-handling code enforces it yet (see +[Tokens and API keys](../api/tokens-and-api-keys.md#personal-access-tokens)) — so today there is no +way to hand an agent a narrower token than its owner's own full role and org access. Treat every +PAT you configure into an MCP client as equivalent to handing that agent your own login for as +long as the token is valid. + +A few narrower guards live in the MCP layer itself, on top of (not instead of) backend +enforcement: + +- `crm_search` caps `limit` at 50 no matter what the agent asks for, so a single call can't pull an + unbounded result set. +- `crm_delete` refuses to run without `confirm=true`. +- `crm_action` refuses an outward-facing action — currently just `send` (emailing a customer an + invoice) — without `confirm=true` too, mirroring the delete guard so a model can't trigger a + real-world side effect off a misread instruction. + +Beyond that, the MCP server does no authorization of its own: it forwards each tool call to the +CRM API verbatim and surfaces whatever the API returns, success or error (`CrmClient._request` +raises `CrmError` whenever `resp.status_code >= 400`, carrying the DRF error body — a `3xx` +response isn't treated as an error by this check and instead falls through to `resp.json()`, +which would raise its own decode error if that response body isn't JSON). +RLS, role checks and per-object ownership rules are all enforced exactly once, in the backend, the +same as for the web app or the mobile app. + +To cut off an agent immediately, revoke its PAT. There are two ways to do that, and which one +applies depends on your role. An admin can revoke any token in the org from the CRM at +**Settings → API tokens** (`/settings/api-tokens`); that page's revoke action calls the +admin-only `DELETE /api/org/tokens/{id}/`, and the whole page is gated on that same admin-only +oversight list (`GET /api/org/tokens/`) — a non-admin who opens it sees "Admins only", not a token +list, and can't reach any revoke control there at all. Any role can instead call +`DELETE /api/profile/tokens/{id}/` directly against their **own** token — a separate, self-service +endpoint that no UI on this settings page currently calls — which is the option to use if the PAT +you need to revoke is yours and you aren't an admin. diff --git a/docs/integrations/python-sample.md b/docs/integrations/python-sample.md new file mode 100644 index 00000000..63de6889 --- /dev/null +++ b/docs/integrations/python-sample.md @@ -0,0 +1,177 @@ +# Python sample + +## What this does + +A complete, runnable script that talks to the BottleCRM REST API from plain Python: it obtains an +access token, lists the org's leads, and creates one. + +"Obtains a token" here means reading a **personal access token** (`bcrm_pat_…`) from the +environment, rather than driving one of the interactive sign-in flows on +[Authentication](../api/authentication.md). A PAT is what BottleCRM issues specifically so a script +doesn't need a browser, an OAuth client secret, or a magic-link email round trip just to make API +calls — see [Tokens and API keys](../api/tokens-and-api-keys.md#personal-access-tokens) for how it +authenticates and what it inherits. There are two ways to mint one, and which applies depends on +your role: an **admin** can do it from the CRM's **Settings → API tokens** page +(`/settings/api-tokens`) — that page is admin-only (it lists every token in the org, not just your +own), so a non-admin who opens it sees "Admins only" and never reaches the create form. **Any +role** can instead call `POST /api/profile/tokens/` directly — the same self-service endpoint the +settings page's own create form posts to — which is the option to use if you aren't an admin, or +you just want a token without the UI. For a local backend you're running yourself, the fastest way +is the Django shell: + +```bash +cd backend +uv run python manage.py shell -c " +from common.models import Profile, PersonalAccessToken +p = Profile.objects.filter(role='ADMIN', is_active=True).first() +print(PersonalAccessToken.generate(p, 'python-sample')[0]) +" +``` + +This prints the raw `bcrm_pat_…` token once — copy it, there's no way to retrieve it again later. + +## The script + +```python +#!/usr/bin/env python3 +"""List an org's leads and create one, using a BottleCRM personal access token. + +Usage: + BCRM_BASE_URL=http://localhost:8000 BCRM_TOKEN=bcrm_pat_... python leads_sample.py + +See docs/api/tokens-and-api-keys.md for how to mint BCRM_TOKEN, and +docs/api/leads.md for the shape of the /api/leads/ responses this script reads. +""" + +import os +import sys +import time + +import requests + +BASE_URL = os.environ.get("BCRM_BASE_URL", "http://localhost:8000").rstrip("/") +TOKEN = os.environ.get("BCRM_TOKEN") + +if not TOKEN: + sys.exit( + "Set BCRM_TOKEN to a bcrm_pat_... personal access token " + "(see docs/api/tokens-and-api-keys.md)." + ) + +session = requests.Session() +session.headers.update( + { + "Authorization": f"Bearer {TOKEN}", + "Accept": "application/json", + } +) + + +def list_leads(): + """GET /api/leads/ — the response splits leads into open/closed sections + rather than one flat list; see docs/api/leads.md#list-leads.""" + resp = session.get(f"{BASE_URL}/api/leads/") + resp.raise_for_status() + data = resp.json() + open_section = data["open_leads"] + leads = open_section["open_leads"] + print( + f"{len(leads)} open lead(s) on this page " + f"(of {open_section['leads_count']} total open):" + ) + for lead in leads: + name = f"{lead.get('first_name') or ''} {lead.get('last_name') or ''}".strip() + print( + f" - {name or '(no name)'} <{lead.get('email') or 'no email'}> " + f"[{lead.get('status')}]" + ) + return data + + +def create_lead(): + """POST /api/leads/ — a 200 with {"error": false, ...} is success; the + response does not echo the created record, so we look it up afterward.""" + email = f"sample.lead+{int(time.time())}@example.com" + payload = { + "first_name": "Sample", + "last_name": "Lead", + "email": email, + "status": "assigned", + "source": "other", + } + resp = session.post(f"{BASE_URL}/api/leads/", json=payload) + body = resp.json() + if resp.status_code >= 400 or body.get("error"): + sys.exit(f"Create failed ({resp.status_code}): {body}") + print(f"Create response: {body}") + return email + + +def find_by_email(email): + """GET /api/leads/?email=... — `email` is a case-insensitive contains + filter (docs/api/conventions.md#filtering-and-search), which is fine here + since the address we just created is unique in this org.""" + resp = session.get(f"{BASE_URL}/api/leads/", params={"email": email}) + resp.raise_for_status() + matches = resp.json()["open_leads"]["open_leads"] + if matches: + print(f"Found the new lead: id={matches[0]['id']}") + else: + print("New lead not on this page (increase limit/offset to find it).") + + +if __name__ == "__main__": + print("Listing existing leads...") + list_leads() + print("\nCreating a lead...") + new_email = create_lead() + print("\nConfirming it was created...") + find_by_email(new_email) +``` + +## Running it + +Save the script as `leads_sample.py`. You need a reachable BottleCRM backend (local or +self-hosted) and a personal access token for a profile in the org you want to query, minted as +described [above](#what-this-does). + +`requests` isn't a dependency of this documentation site or of the CRM's own frontend — the +simplest way to run the script without installing anything into a project is `uv run` with an +inline dependency: + +```bash +BCRM_BASE_URL=http://localhost:8000 \ +BCRM_TOKEN=bcrm_pat_... \ + uv run --with requests python leads_sample.py +``` + +Or, with a regular virtualenv: `pip install requests` and then +`python leads_sample.py` with the same two environment variables set. + +**A note on verification:** this script was checked by static means — every URL and field against +[Leads](../api/leads.md) and [Conventions](../api/conventions.md), and the whole script for valid +Python syntax — rather than executed against a live database, to avoid writing a row into a shared +instance this documentation didn't provision. The example output below is illustrative, not a +captured run: + +```text +Listing existing leads... +3 open lead(s) on this page (of 3 total open): + - Jordan Blake [assigned] + - Priya Shah [in process] + - (no name) [assigned] + +Creating a lead... +Create response: {'error': False, 'message': 'Lead Created Successfully'} + +Confirming it was created... +Found the new lead: id=3f2a1c9e-.... +``` + +A validation failure — for example, running the script twice in the same second so the generated +email collides — comes back as a `400` and the script exits with the error body BottleCRM returned, +per [Errors](../api/errors.md#validation-errors): + +```text +Create failed (400): {'error': True, 'errors': {'email': ['Another lead in this organisation already uses that email address.']}} +``` diff --git a/docs/mobile/build-and-configure.md b/docs/mobile/build-and-configure.md new file mode 100644 index 00000000..5337c39c --- /dev/null +++ b/docs/mobile/build-and-configure.md @@ -0,0 +1,109 @@ +# Build and configure + +This page covers getting the Flutter source running from a clone — dependencies, local runs, and +the test/lint commands. It does not cover pointing the app at your own backend or producing a +signed release build; see [Connect to a self-hosted backend](connect-to-self-hosted.md) and +[Release builds](release-builds.md) for those. + +## Prerequisites + +`mobile/pubspec.yaml` declares: + +```yaml +environment: + sdk: ^3.8.1 +``` + +This is the only entry under `environment:` — there is no `flutter:` key. In a Flutter +`pubspec.yaml`, the `sdk:` constraint under `environment:` pins the **Dart** SDK, not the Flutter +SDK; a `flutter: ">=x.y.z"` line would pin Flutter separately, and this project doesn't have one. +So the project itself declares no explicit Flutter version floor — `^3.8.1` only says "Dart SDK +3.8.1 or newer, below 4.0.0." + +That said, the checked-in `mobile/pubspec.lock` records the SDK constraints actually resolved +against the full dependency graph, in its trailing `sdks:` block: + +```yaml +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" +``` + +This is narrower than the project's own `^3.8.1` floor because several dependencies (for example +`flutter_riverpod: ^3.3.1`, `go_router: ^17.2.3`, `google_fonts: ^8.1.0`) have their own, higher +SDK requirements, and `pub` resolves to the intersection of all of them. In practice, **Flutter +3.44.0 or newer** (which is what ships Dart 3.12+) is the version that will actually resolve this +project's dependencies — that number comes from the lockfile, not from a guess at which Flutter +release maps to which Dart version. There is no `.fvmrc`, and neither of the two GitHub Actions +workflows in this repository (`.github/workflows/tests.yml`, `codeql-analysis.yml`) mentions +Flutter, Dart, or `mobile/` at all, so there's no CI-enforced version either — the lockfile is the +only pin that exists. + +(`mobile/README.md` states "Flutter SDK: 3.8.1 or later" — the same Dart-constraint-copied-as-a- +Flutter-version error. Use the lockfile numbers above instead.) + +Beyond the SDK itself: + +- **Android builds** need the Android SDK (via Android Studio or the standalone command-line + tools). `mobile/android/app/build.gradle.kts` sets `compileSdk = flutter.compileSdkVersion` and + `targetSdk = flutter.targetSdkVersion` — both come from whatever your installed Flutter SDK + defines as current, not from a number hardcoded in this project. It does pin two things itself: + `ndkVersion = "28.2.13676358"` (the comment notes this was "bumped to satisfy jni plugin + requirement") and Java/Kotlin compatibility at **17** (`compileOptions.sourceCompatibility` / + `targetCompatibility = JavaVersion.VERSION_17`, mirrored in the `kotlin { compilerOptions { + jvmTarget } }` block) — you need a JDK 17-compatible toolchain available to Gradle. +- **iOS builds** need Xcode and CocoaPods, and only work on macOS. `mobile/README.md` states "iOS: + iOS 11.0+", but the actual deployment target in `mobile/ios/Runner.xcodeproj/project.pbxproj` is + `IPHONEOS_DEPLOYMENT_TARGET = 12.0`, set identically across all three build configurations + (Debug, Release, Profile) — 12.0 is the real floor. + +## Getting dependencies + +```bash +cd mobile +flutter pub get +``` + +This resolves and downloads the packages declared in `pubspec.yaml` into your local pub cache and +writes/updates `pubspec.lock` (already checked in — `flutter pub get` should reproduce it exactly +if nothing has changed upstream). Android's Firebase/Crashlytics integration +(`firebase_core`, `firebase_crashlytics`) is wired up for Android only — see +[Connect to a self-hosted backend](connect-to-self-hosted.md#google-sign-in-configuration) for +what that does and doesn't require from you. + +## Running against a local backend + +`flutter run` starts the app in debug mode against whichever device or emulator is attached +(`flutter devices` lists them). **Before you do this, read [Connect to a self-hosted +backend](connect-to-self-hosted.md) first** — the backend URL the debug build talks to is a +compile-time constant in `lib/config/api_config.dart`, and as shipped it does **not** point at +`localhost` or any backend you're likely to be running yourself. Running `flutter run` without +changing that constant first will build successfully and simply talk to a stranger's backend. + +Once `lib/config/api_config.dart` points at your own backend: + +```bash +flutter run +``` + +Press `r` for hot reload, `R` for hot restart, `q` to quit. If you change the URL constant while +the app is already running, restart the run (`R`, or stop and re-run) rather than relying on hot +reload — see the compile-time-constant caveat on the linked page. + +## Tests and analysis + +```bash +flutter test # run the test suite +flutter analyze --no-fatal-infos # static analysis (analysis_options.yaml + flutter_lints) +dart format . # format all Dart source +``` + +`mobile/README.md` describes a `test/unit/`, `test/widget/`, `test/integration/` structure. As of +this writing the actual `test/` tree holds one file, +`test/screens/deals/deals_list_screen_test.dart`, a widget test for the deals list screen — the +three-way split described in the README doesn't exist yet. `flutter test` runs whatever is there +regardless of directory layout, so the command itself is accurate even though the described +structure isn't. + +`flutter analyze --no-fatal-infos` runs against the rules in `analysis_options.yaml`, which +includes `package:flutter_lints/flutter.yaml` with no project-specific rules added or disabled. diff --git a/docs/mobile/connect-to-self-hosted.md b/docs/mobile/connect-to-self-hosted.md new file mode 100644 index 00000000..bf9a170c --- /dev/null +++ b/docs/mobile/connect-to-self-hosted.md @@ -0,0 +1,221 @@ +# Connect to a self-hosted backend + +Read this page before you run or build the mobile app against anything other than +`api.bottlecrm.io`. The short version: the backend URL is baked into the binary at compile time, +there is no way to point a build at a different server without editing source and rebuilding, and +the two URLs shipped in source today aren't `localhost` or any generic placeholder — they're a +specific developer's tunnel and BottleCRM's own production API. + +## The compile-time base URL + +`mobile/lib/config/api_config.dart` (lines 12–19): + +```dart +/// Development API URL +static const String _developmentUrl = 'https://msi-8000.rcdev.in'; + +/// Production API URL +static const String _productionUrl = 'https://api.bottlecrm.io'; + +/// Get the current base URL based on build mode +static String get baseUrl => kDebugMode ? _developmentUrl : _productionUrl; +``` + +Both URLs are `static const` Dart strings, and the choice between them is `kDebugMode` — a +compile-time flag set by which `flutter run`/`flutter build` mode you use, not anything read at +runtime. There is no `--dart-define`, no `String.fromEnvironment` anywhere in `lib/`, and no +runtime settings screen: grepping `lib/` for `SharedPreferences` turns up exactly four files, and +none of them touch the base URL — `auth_service.dart` (auth tokens/user data), list-view +filter/sort state in `screens/tickets/tickets_list_screen.dart` and +`screens/deals/deals_list_screen.dart`, and `providers/tickets_provider.dart`, which only defines +the JSON-serialization helper (`TicketListFilters.toJson()`) that `tickets_list_screen.dart` +persists — it doesn't call the `SharedPreferences` API itself. Whatever `baseUrl` resolves to when +the app is *compiled* is what it talks to for the life of that install. + +The practical consequences: + +- **A store-distributed release build can only ever talk to `https://api.bottlecrm.io`.** If + you're self-hosting, a generic APK/AAB/IPA built from this source with the constants unchanged + will authenticate against and read/write data on BottleCRM's own hosted API, not yours. There is + no in-app way to redirect it. +- **A debug build (`flutter run`) does not point at `localhost` either.** `_developmentUrl` is + `https://msi-8000.rcdev.in` — an individual developer's personal tunnel/host, not a generic + loopback address, and not one you control. If you want to run the app against a backend on your + own machine, you have to edit this constant regardless of whether you're in debug or release + mode. +- **This means self-hosting the mobile app is source-edit-and-rebuild, not configure-and-run.** + There's no middle ground between "use BottleCRM's hosted API" and "maintain your own fork of + this one file." If that's a blocker for your deployment, treat it as a known limitation rather + than something you missed a flag for. + +## Pointing at your backend + +1. Edit `mobile/lib/config/api_config.dart` and change `_developmentUrl` and/or `_productionUrl` + to your backend's URL — which one(s) you need depends on which build modes you'll use. If you + don't need a separate dev/prod split, it's simplest to set both to the same value. +2. Rebuild. Because these are compile-time constants, don't rely on hot reload to pick up the + change reliably — stop and re-run (`flutter run`) for a debug build, or do a full `flutter build + apk --release` / `appbundle --release` / iOS build (see [Release builds](release-builds.md)) for + a release one. + +A few things to get right about the URL itself: + +- **Scheme and reachability depend on where the app runs, not just what your backend serves.** + - An **Android emulator** does not see the host machine as `localhost` — from inside the + emulator, `localhost`/`127.0.0.1` refers to the emulator itself. The documented address for + the host loopback interface is `10.0.2.2`. If your backend is `docker compose up` on your dev + machine (see [Docker](../self-hosting/docker.md), which binds `0.0.0.0:8000`), point the + emulator build at `http://10.0.2.2:8000`, not `http://localhost:8000`. + - An **iOS Simulator** runs natively on the Mac (it isn't a separate virtual network namespace + the way the Android emulator is), so `http://localhost:8000` from inside the simulator does + reach a backend bound on the host Mac. + - A **physical device** (Android or iOS) can't reach "the developer machine" via `localhost` or + `10.0.2.2` at all — it needs your machine's LAN IP address, both devices on the same network, + and no firewall blocking the port. +- **Both platforms default to blocking plain HTTP, and nothing in this project opts back in — but + whether that default actually reaches this app's traffic isn't something this repository can + settle.** The two policies, and what's (not) configured for each: + - **Android** disables cleartext traffic by default for apps whose `targetSdkVersion` is 28 or + higher — that's keyed on `targetSdk`, not `minSdk`. `android/app/build.gradle.kts` pins + `minSdk = 28` (line 40), but `targetSdk = flutter.targetSdkVersion` on the next line is *not* + pinned in this project — it tracks whatever Flutter SDK you have installed (see + [Release builds](release-builds.md)). Either way, there's no `android:usesCleartextTraffic` + attribute and no `android:networkSecurityConfig` in `AndroidManifest.xml`, and no + `network_security_config.xml` resource anywhere under `android/app/src/main/res/` (checked + directly) — nothing here opts back into cleartext. + - **iOS** has the equivalent default: App Transport Security blocks plain HTTP unless the app's + `Info.plist` carries an `NSAppTransportSecurity` exception, and `ios/Runner/Info.plist` has + none. + - On both platforms, though, that default is normally enforced by the platform's own HTTP stack + — Android's `NetworkSecurityPolicy`, consulted by `HttpURLConnection`/OkHttp/WebView; iOS's + App Transport Security, enforced inside `NSURLSession`/CFNetwork. This app's requests don't + obviously go through either: `api_service.dart` builds its client from `package:http`'s + default `Client()` (`final http.Client _client = http.Client();`), which on a non-web platform + resolves to `IOClient`, a thin wrapper around `dart:io`'s own `HttpClient` (`http` package + source, `lib/src/client.dart` / `lib/src/io_client.dart`) — not `cronet_http` or + `cupertino_http`, neither of which is a dependency here. Whether `dart:io`'s socket layer on + either platform consults the OS's cleartext/ATS policy at all isn't something reading source + can settle; it would take an actual build tested against a plain-HTTP host, which is out of + scope for this page. + + Treat HTTPS as required regardless of how that ambiguity resolves in practice: this connection + carries JWTs and customer data (see [above](#the-compile-time-base-url)), and it should be + encrypted on its own merits, not because a platform default happens to force it. If you do need + to test against the plain-HTTP [Docker](../self-hosting/docker.md) stack directly, try it — if + the connection fails in a way that looks like a cleartext/ATS block, the Android-side escape + hatch is adding your own `android:usesCleartextTraffic="true"` (or a scoped + `network_security_config.xml`) to `AndroidManifest.xml`; the durable fix on either platform is + putting a TLS-terminating [reverse proxy](../self-hosting/production-deploy.md#reverse-proxy) in + front of your backend, since nothing in the backend itself terminates TLS either. + +## Google Sign-In configuration + +[Magic-link email sign-in](../getting-started/first-sign-in.md#magic-links) needs no +configuration on either end and works against any backend. Google Sign-In needs configuration on +**both** the backend and the mobile app, and the two have to agree with each other — this is more +involved than the [web app's Google OAuth setup](../self-hosting/google-oauth.md), because the +mobile flow is [ID-token-based, not +redirect-based](../self-hosting/google-oauth.md#mobile-id-tokens). + +**Backend side** (prerequisite, covered in full on [Google +OAuth](../self-hosting/google-oauth.md)): `GOOGLE_CLIENT_ID` must be set to a real Google OAuth +Web client ID, because `GoogleIdTokenView` verifies every ID token's audience against exactly that +value (`google.oauth2.id_token.verify_oauth2_token(..., settings.GOOGLE_CLIENT_ID)`). Leave it +blank and Google Sign-In stays off — magic links are the only path in. + +**Android side** — three things, all tied to BottleCRM's own Google Cloud project as shipped: + +1. `mobile/lib/services/auth_service.dart` (lines 70–73) hardcodes a **Web** OAuth client ID as + `serverClientId`: + ```dart + await _googleSignIn.initialize( + serverClientId: + '1072513761792-p59rct7b1c3go7l58e51r3geuqff2tfl.apps.googleusercontent.com', + ); + ``` + The code comment identifies this as "the audience the Django backend's `GOOGLE_CLIENT_ID` + verifies against." For your self-hosted backend to accept tokens from your own mobile build, + this constant must be a Web client ID from **your own** Google Cloud project, and your + backend's `GOOGLE_CLIENT_ID` must be that same value — reusing BottleCRM's own client ID here + will not work against your backend, because your `GOOGLE_CLIENT_ID` won't match it. +2. Android sign-in additionally needs an **Android**-type OAuth client (separate from the Web + client above) registered in Google Cloud, tied to your app's `applicationId` and the SHA-1 + fingerprint of whatever certificate signs the build. Get the fingerprint with `cd android && + ./gradlew signingReport` — this needs `android/local.properties` to already exist with a + `flutter.sdk` entry (`settings.gradle.kts` `require`s it and fails with `"flutter.sdk not set in + local.properties"` otherwise); that file is gitignored (`android/.gitignore:6`) and is written + by Flutter's own Gradle-invoking commands (`flutter run`, `flutter build ...`), not by `flutter + pub get` alone, so make sure you've run one of those at least once first. This is also why + `build.gradle.kts`'s `debug` build type signs with the release key when `key.properties` exists + (see [Release builds → Signing](release-builds.md#signing)) rather than the machine-local + Android Gradle Plugin debug keystore — so a locally installed debug build's SHA-1 matches what + you registered. +3. Firebase/Crashlytics config (`android/app/google-services.json`) is required for the Gradle + build to succeed at all once the `com.google.gms.google-services` plugin is applied (it is, + unconditionally, in `android/app/build.gradle.kts`) — only a placeholder + `google-services.json.template` is committed; you supply the real file from your own + Firebase/Google Cloud project. See [Release builds](release-builds.md) for what that plugin + does if the file is missing. + +**iOS side — not configured in this repository at all as shipped**, independent of anything you +change on Android. The native SDK the `google_sign_in_ios` plugin wraps (pinned at `6.3.0` in +`pubspec.lock`) can get its client ID from three places, checked in this order by the plugin's own +`configureWithParameters:` handler (`FLTGoogleSignInPlugin.m:180-191`): + +1. a runtime `clientId:` argument passed to `GoogleSignIn.instance.initialize(...)`; +2. a bundled `GoogleService-Info.plist`'s `CLIENT_ID` key; +3. `Info.plist`'s own `GIDClientID` key, which `GIDSignIn` reads automatically as its default when + neither of the above is set — the plugin's own source comment calls this "the recommended + configuration method." + +All three are absent here: `auth_service.dart`'s `initialize()` call passes only `serverClientId`, +never `clientId`; `ios/Runner/GoogleService-Info.plist` doesn't exist (gitignored, and there's no +committed template the way Android has one); and grepping the whole `ios/` tree for `GIDClientID`, +`CFBundleURLTypes`, and `REVERSED_CLIENT_ID` returns nothing. + +That doesn't raise an error at startup, though. When all three sources are absent, the plugin's +internal `configurationWithClientIdentifier:...` returns `nil`, and the handler simply skips +assigning a new configuration (`if (configuration) { self.signIn.configuration = configuration; }`) +rather than failing — `GIDSignIn`'s configuration is just left unset. `auth_service.dart`'s +`initialize()` call is wrapped in a `try`/`catch` that only `debugPrint`s on error regardless, so +the app starts up looking normal on iOS either way. The gap only becomes visible when a user +actually taps "Sign in with Google" and there's no configuration to sign in with. + +**The least invasive fix is source #3 above — the `Info.plist` route `GIDSignIn` itself +recommends — since it needs no Dart code changes:** + +```xml + +GIDClientID +YOUR-IOS-CLIENT-ID.apps.googleusercontent.com +``` + +plus the URL-scheme callback handler, which the `google_sign_in_ios` package's own README says is +required regardless of which of the three routes you use for the client ID itself: + +```xml +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.YOUR-REVERSED-CLIENT-ID + + + +``` + +Both values come from an iOS-type OAuth client in your Google Cloud project — a +`GoogleService-Info.plist` for that project has them under `CLIENT_ID` and `REVERSED_CLIENT_ID` +respectively, or you can copy them from the Cloud Console directly without ever downloading that +file. The alternative is passing `clientId:` (and optionally `serverClientId:`) as arguments to +`_googleSignIn.initialize(...)` in `auth_service.dart` instead of setting `GIDClientID` (source #1 +instead of #3) — but the `CFBundleURLTypes` entry above is still required either way. + +This is separate from Firebase Crashlytics, which is deliberately Android-only in this codebase — +`main.dart` explicitly guards Firebase initialization behind `defaultTargetPlatform == +TargetPlatform.android` with a comment noting iOS has no `GoogleService-Info.plist` "yet." Don't +read the Crashlytics gap as evidence the Google Sign-In gap is equally intentional; the two are +separate config surfaces that happen to share the same missing file. diff --git a/docs/mobile/release-builds.md b/docs/mobile/release-builds.md new file mode 100644 index 00000000..af916bbf --- /dev/null +++ b/docs/mobile/release-builds.md @@ -0,0 +1,163 @@ +# Release builds + +This page covers producing an installable Android/iOS artifact and what signing configuration +already exists in the repository versus what you have to create yourself. It assumes you've +already pointed the app at your own backend — see [Connect to a self-hosted +backend](connect-to-self-hosted.md), since building a release binary bakes in whatever +`lib/config/api_config.dart` says at build time, same as a debug build. + +Both platforms read app identity and version from a few shared sources: `applicationId` +`io.bottlecrm` (Android, `android/app/build.gradle.kts`) and bundle identifier `io.bottlecrm` (iOS, +`ios/Runner.xcodeproj/project.pbxproj`) are the same string on both platforms; `pubspec.yaml`'s +`version: 1.2.0+11` supplies both the build name (`1.2.0`) and build number (`11`) via +`flutter.versionName`/`flutter.versionCode` on Android and `$(FLUTTER_BUILD_NAME)`/ +`$(FLUTTER_BUILD_NUMBER)` on iOS. `io.bottlecrm` is BottleCRM's own identifier — it's also the +package ID of BottleCRM's own Play Store listing referenced in `mobile/README.md`. If you're +distributing your own build through your own Play Console/App Store Connect listing, you'll want +your own identifier here, which has knock-on effects on the Google Sign-In client registrations +covered on [Connect to a self-hosted backend](connect-to-self-hosted.md#google-sign-in-configuration) +(the Android OAuth client and `google-services.json`'s `package_name` are both tied to whatever +`applicationId` you ship). For a sideloaded APK you build and install yourself, changing it isn't +necessary. + +## Android + +```bash +flutter build apk --release # single APK, build/app/outputs/flutter-apk/app-release.apk +flutter build appbundle --release # Play Store .aab, build/app/outputs/bundle/release/ +``` + +Real minimum/target/compile SDK levels, from `android/app/build.gradle.kts`: + +- `minSdk = 28` (Android 9) — set directly in this file, with the comment `// Android 9 (API 28)`. + `mobile/README.md`'s "Android: API level 31+ (Android 12+)" and `pubspec.yaml`'s + `flutter_launcher_icons.min_sdk_android: 31` (which only controls whether that tool generates + adaptive icons, not the app's own floor) both say something different from the actual Gradle + config — 28 is what the build enforces. +- `compileSdk = flutter.compileSdkVersion`, `targetSdk = flutter.targetSdkVersion` — both come + from your installed Flutter SDK's defaults, not a number pinned in this project. + +The build also requires `ndkVersion = "28.2.13676358"` (pinned in the same file, "Bumped to +satisfy jni plugin requirement") and a JDK 17-compatible toolchain (`compileOptions` and the +`kotlin { compilerOptions { jvmTarget } }` block both target Java/Kotlin 17) — see [Build and +configure](build-and-configure.md#prerequisites) for the rest of the local environment needs. + +`android/app/build.gradle.kts` applies the `com.google.gms.google-services` and +`com.google.firebase.crashlytics` Gradle plugins unconditionally (classpath versions pinned in +`android/build.gradle.kts`: `com.google.gms:google-services:4.5.0`, +`com.google.firebase:firebase-crashlytics-gradle:3.0.7`). The google-services plugin's documented +behavior is to fail the build outright if `android/app/google-services.json` isn't present. This +repository ships only `android/app/google-services.json.template` — the real file is gitignored +(`**/google-services.json` in `mobile/.gitignore`) and not present in a fresh clone. You need your +own `google-services.json` from a Firebase/Google Cloud project before `flutter build apk` or +`flutter build appbundle` will get past that plugin — see [Connect to a self-hosted +backend](connect-to-self-hosted.md#google-sign-in-configuration) for what that file is for beyond +just satisfying the build. + +## iOS + +macOS and Xcode only. + +```bash +flutter build ios --release # produces build/ios/iphoneos/Runner.app; needs code signing +flutter build ios --release --no-codesign # same, skipping signing (e.g. building without a team configured yet) +``` + +For an archive ready to upload to App Store Connect, `flutter build ipa --release` is the +Flutter-managed equivalent of `appbundle` for iOS (it drives `xcodebuild -archive` and an export +step, and needs valid signing to succeed — see below). + +The real deployment target is `IPHONEOS_DEPLOYMENT_TARGET = 12.0`, set identically in all three +build configurations in `ios/Runner.xcodeproj/project.pbxproj` (Debug, Release, Profile) — +`mobile/README.md`'s "iOS: iOS 11.0+" doesn't match the project file. + +Firebase/Crashlytics is deliberately not wired up for iOS in this codebase (`main.dart` skips +`Firebase.initializeApp` outside `TargetPlatform.android`, with a comment noting there's no iOS +`GoogleService-Info.plist` yet), so there's no equivalent of the Android google-services plugin +gate blocking an iOS build — a plain `flutter build ios --release --no-codesign` will get through +without any Firebase config. Getting past code signing is the separate concern covered next. + +## Signing + +**Android.** `android/app/build.gradle.kts` loads a `key.properties` file from the Android project +root, if one exists: + +```kotlin +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} +``` + +It reads four properties from that file — `keyAlias`, `keyPassword`, `storeFile`, +`storePassword` — to build a `release` signing config, and applies that to the `release` build +type. **`key.properties` doesn't ship in this repository** — it's listed in +`android/.gitignore` and there's no committed template for it the way there is for +`google-services.json`. Critically, look at what happens to the `release` build type when the +file is absent: + +```kotlin +buildTypes { + release { + signingConfig = if (keystorePropertiesFile.exists()) + signingConfigs.getByName("release") + else + signingConfigs.getByName("debug") + } + ... +} +``` + +**On a fresh clone, with no `key.properties` created, `flutter build apk --release` and `flutter +build appbundle --release` fall back to the Android Gradle Plugin's built-in `debug` signing +config** — not a Flutter-provided key, and not one shared across installations. That config is +backed by a `debug.keystore` the Android SDK tooling generates itself, per machine, the first time +one is needed (conventionally at `~/.android/debug.keystore`, with a fixed alias/password pair +`androiddebugkey`/`android`) — Flutter has no involvement in it beyond invoking Gradle, and two +different machines will generate two different debug keystores. That has a concrete consequence +beyond "not a real release key": a debug-signed release build from your laptop and one from your +CI machine carry different signatures, and Android treats differently-signed builds as unrelated +apps for update purposes — one can't silently update an install that came from the other, or from +a build signed with a real upload key later. That's fine for a build you sideload once and don't +expect to update; it is not a build you should distribute publicly, since anyone can reproduce a +"signed" update off their own machine's debug keystore too. Before a release build is meant to go +further than your own device, generate your own keystore — the standard `keytool` invocation is: + +```bash +keytool -genkey -v -keystore .jks -keyalg RSA -keysize 2048 \ + -validity 10000 -alias +``` + +— and create `android/key.properties` (never commit it) with `storeFile`, `storePassword`, +`keyAlias`, `keyPassword` pointing at it. + +One more effect of that same conditional worth knowing about, on the *debug* build type: + +```kotlin +debug { + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } +} +``` + +If `key.properties` exists, **debug builds are signed with your release key too**, not the +machine-local Android Gradle Plugin debug keystore described above. The code comment explains why: +so a debug build installed for local testing has the same SHA-1 fingerprint as what you registered +for Google Sign-In (see [Connect to a self-hosted backend → Google Sign-In +configuration](connect-to-self-hosted.md#google-sign-in-configuration)), rather than needing a +second, separate OAuth Android client just for debug installs. + +**iOS.** `ios/Runner.xcodeproj/project.pbxproj` sets `CODE_SIGN_IDENTITY[sdk=iphoneos*] = "iPhone +Developer"` for device builds at the project level, inherited by both targets. +`CODE_SIGN_STYLE = Automatic` also appears in the file, but only on the **`RunnerTests`** target's +three build configurations — the `Runner` app target and the project level don't set +`CODE_SIGN_STYLE` at all, and the project's `TargetAttributes` block doesn't set a +`ProvisioningStyle` for either target either. What is consistent across the whole file: there is +no `DEVELOPMENT_TEAM` key anywhere in it (checked directly; it's simply absent), so regardless of +what signing style Xcode shows you when you first open the project, you'll need to open +`ios/Runner.xcworkspace` in Xcode, select the Runner target's Signing & Capabilities tab, and pick +your Apple Developer team before `flutter build ios` (without `--no-codesign`) or `flutter build +ipa` will succeed. There's no keystore-equivalent file to create by hand here — Xcode manages +provisioning profiles itself once a team is selected. diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md new file mode 100644 index 00000000..0b632ab0 --- /dev/null +++ b/docs/reference/environment-variables.md @@ -0,0 +1,114 @@ +# Environment variables + +This page is the exhaustive table: every environment variable `backend/crm/settings.py` reads, +its real default, and what it's for. [Self-hosting: Environment variables](../self-hosting/environment-variables.md) +is the narrative companion — how configuration loading actually works (`load_dotenv()`, the +`.env.docker` / `.env.docker.local` layering), and the smaller, curated list of variables an +operator actually has to touch to run a working, non-default install. Read that page first if +you're setting up a deployment; come back here when you need the complete list or an exact default. + +## How to set them + +Every variable in the table below is read with `os.environ.get("NAME", )` (or the +equivalent `os.getenv`) directly in `crm/settings.py`, at process import time — `manage.py +runserver`, `migrate`, a Gunicorn/Uvicorn worker, a Celery worker, and every management command all +read the same values the same way. A `.env` file next to `manage.py` (for a manual install) or +`.env.docker`/`.env.docker.local` (for the Docker Compose stack) is loaded into the process +environment before any of these calls run, so an unset variable falls back to the default in the +**Default** column rather than crashing immediately. That's true even for `SECRET_KEY`: its own +`os.environ.get` call always succeeds, but the value it produces then feeds a separate check a few +lines later that raises `ValueError` once `ENV_TYPE` is anything other than `dev` (see the +`SECRET_KEY` row below) — a default that satisfies `os.environ.get` doesn't necessarily satisfy +everything downstream of it. The variables with genuinely no default in code — read with plain +`os.environ[...]`, so a missing one is an immediate `KeyError` rather than a fallback — live outside +`crm/settings.py` entirely and are covered in the list below instead of the table. + +Three groups of real, project-read environment variables are **not** read by `crm/settings.py` +itself, so they aren't in the table below: + +- **`ADMIN_PASSWORD`** is read directly by the `create_default_admin` management command (see + [Management commands](management-commands.md#create_default_admin)) — it still gets its own row + below because it's part of the same Docker bootstrap flow as `ADMIN_EMAIL`, which *is* read in + `settings.py`. +- **`AWS_BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `SENTRY_DSN`** are read with + plain `os.environ[...]` (no default) inside `backend/crm/server_settings.py`, which + `crm/settings.py` only imports when `ENV_TYPE=prod`. A missing one is a `KeyError` at import + time — the process refuses to start rather than starting half-configured. `ENV_TYPE` itself + follows the same rule: it's read in `settings.py`, but it's the internal dev/prod switch this + project's own tests exclude from the table by design, not a self-hoster-facing setting on its + own. See [Production deployment](../self-hosting/production-deploy.md#required-settings) and + [Security hardening](../self-hosting/security-hardening.md#secrets) for what `ENV_TYPE=prod` + actually requires and why it shouldn't be set casually. +- **`BCRM_MCP_ENABLED`** and **`BCRM_BASE_URL`** are two different knobs, both read in + `backend/crm/asgi.py`, not `crm/settings.py`. Only `BCRM_MCP_ENABLED` (default `"true"`) gates + whether the optional MCP server is mounted at `/mcp` at all — anything in `0`/`false`/`no`/`off` + disables it, anything else (including unset) leaves it enabled. `BCRM_BASE_URL` (default + `http://127.0.0.1:8000`) has no bearing on whether the mount happens; it's the REST API root the + mounted MCP tools call once it's live. See [MCP server](../integrations/mcp-server.md) for both. + +## Reference + +### Django core + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `SECRET_KEY` | `django-insecure-dev-key-please-change-in-production` | Yes, once `ENV_TYPE` is anything other than `dev` (see above) | Django's cryptographic signing key. It also *is* the JWT `SIGNING_KEY` — `SIMPLE_JWT["SIGNING_KEY"]` is set to `SECRET_KEY` directly, so there is no separate JWT signing key setting anywhere in this codebase, and rotating `SECRET_KEY` invalidates every outstanding access and refresh token. See [Architecture: Authentication](../architecture/authentication.md#token-model) and [Security hardening](../self-hosting/security-hardening.md#secrets). | +| `DEBUG` | `False` | No | Django debug mode. Also the sole gate on the `devlogin` management command — see [Management commands](management-commands.md#devlogin). | +| `ALLOWED_HOSTS` | `localhost,127.0.0.1` | Yes, once serving traffic under a real hostname | Comma-separated list, split on `,` into Django's `ALLOWED_HOSTS`. | + +### Database + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `DBNAME` | `crm_db` | No | PostgreSQL database name. | +| `DBUSER` | `postgres` | Yes, before anyone but you can reach the instance | PostgreSQL role. The default is the PostgreSQL superuser account on most installations, and a superuser silently bypasses every RLS policy this project relies on for tenant isolation — see [PostgreSQL and RLS: Creating the application role](../self-hosting/postgresql-and-rls.md#creating-the-application-role). | +| `DBPASSWORD` | `postgres` | Yes, alongside `DBUSER` | PostgreSQL password. | +| `DBHOST` | `localhost` | No | PostgreSQL host. | +| `DBPORT` | `5432` | No | PostgreSQL port. | + +### Email + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `EMAIL_BACKEND` | `django.core.mail.backends.console.EmailBackend` | No | Django email backend. The default prints outbound mail to stdout instead of sending it. Setting `ENV_TYPE=prod` overrides this unconditionally to `django_ses.SESBackend`, regardless of what this variable is set to — see [Email and Celery](../self-hosting/email-and-celery.md#amazon-ses). | +| `DEFAULT_FROM_EMAIL` | `noreply@localhost` | No | `From` address on outbound mail. | +| `ADMIN_EMAIL` | `admin@localhost` | No | Assigned to `settings.ADMIN_EMAIL` but not consumed anywhere else in `settings.py` itself. The one place it's read outside this module and `create_default_admin` (see below) is `common/views/user_views.py`, which returns it as `admin_email` in the user-list API response context. `create_default_admin` also reads `ADMIN_EMAIL` a second time, independently and with the same default, to name the bootstrap Django superuser account. | +| `AWS_SES_REGION_NAME` | `ap-south-1` | No, unless `ENV_TYPE=prod` | Only read when `"django_ses"` appears in `EMAIL_BACKEND` — true if you set `EMAIL_BACKEND=django_ses.SESBackend` directly, or unconditionally true once `ENV_TYPE=prod` forces that backend. Under `ENV_TYPE=prod`, `server_settings.py` requires this variable outright (`os.environ[...]`, no default) before `settings.py`'s own conditional read ever runs, so the default above is effectively unreachable in that mode. | +| `AWS_SES_REGION_ENDPOINT` | `email..amazonaws.com` | No, unless `ENV_TYPE=prod` | Same condition and the same `ENV_TYPE=prod`-makes-it-required caveat as `AWS_SES_REGION_NAME` above. | + +### Celery and Redis + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `CELERY_BROKER_URL` | `redis://localhost:6379/0` | Yes, before anyone but you can reach the instance | Redis URL Celery uses as its message broker. | +| `CELERY_RESULT_BACKEND` | `redis://localhost:6379/0` | Yes, alongside `CELERY_BROKER_URL` | Redis URL Celery uses to store task results. | + +### CORS and CSRF + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `CORS_ALLOW_ALL` | `False` | No | Sets Django's `CORS_ORIGIN_ALLOW_ALL` — note the name doesn't match the Django setting it controls. `"true"` (case-insensitive) allows every origin; anything else leaves it off. | +| `CORS_ALLOWED_ORIGINS` | `http://localhost:5173,http://127.0.0.1:5173` | Yes, once the frontend is served from a real domain | Comma-separated list of allowed CORS origins; each entry is stripped of surrounding whitespace and empty entries are dropped. | +| `CSRF_TRUSTED_ORIGINS` | *(empty — no trusted origins)* | Yes, before anyone but you can reach the instance | Comma-separated list, same stripping rule as `CORS_ALLOWED_ORIGINS`. Each entry needs a scheme, e.g. `https://crm.example.com` — a bare hostname is silently useless to Django's CSRF check. | + +### URLs + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `DOMAIN_NAME` | `http://localhost:8000` | Yes, once self-hosted under a real domain | Base URL embedded in outbound emails' links back to the CRM — this is the **backend's** URL, not necessarily the frontend's. Two uses matter more than the rest: it builds the link an **anonymous client** gets in an emailed invoice/estimate (`invoices/api_views.py`, `invoices/tasks.py`, via `send_invoice_to_client(..., domain=...)`) and the link in a **CSAT survey** email (`cases/tasks.py`, `link = f"{domain}/csat/{raw_token}"`) — both go to people outside the organization, so leaving the `localhost` default in a real deployment doesn't just look wrong internally, it emails dead links to customers. The remaining, lower-stakes uses are internal notification emails (comment-mention, account activation/deactivation, and the per-record "assigned to you" emails in `leads/tasks.py`, `cases/tasks.py`, `opportunity/tasks.py`, `contacts/tasks.py`, `accounts/tasks.py`), which only reach people who already have accounts in the org. | +| `FRONTEND_URL` | `http://localhost:5173` | Yes, once self-hosted under a real domain | Base URL of the SvelteKit frontend. Used to build the welcome email's link and the magic-link sign-in URL (`common/tasks.py`) — the links a recipient is actually meant to click into the app. | +| `SWAGGER_ROOT_URL` | `http://localhost:8000` | No | Read into a Django setting in `crm/settings.py`, but not referenced anywhere else in this codebase (backend, frontend, or mobile) as of this writing — setting it currently has no observable effect. | + +### Google OAuth + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `GOOGLE_CLIENT_ID` | *(empty)* | No — leaving it blank disables Google sign-in | OAuth client ID sent to Google's token endpoint and used as the expected audience when verifying a mobile ID token. See [Google OAuth](../self-hosting/google-oauth.md#backend-configuration). | +| `GOOGLE_CLIENT_SECRET` | *(empty)* | No — leaving it blank disables Google sign-in | OAuth client secret, alongside `GOOGLE_CLIENT_ID` above. | +| `GOOGLE_REDIRECT_URI` | *(empty)* | No | Defined in `settings.py` but not read anywhere else in the backend — the redirect URI actually used in the token exchange is the one the frontend sends in the request body on each call, not this setting. Safe to leave unset even in production; see [Google OAuth](../self-hosting/google-oauth.md#backend-configuration) for why. | + +### Docker bootstrap (not read by `crm/settings.py`) + +| Variable | Default | Required | Purpose | +| --- | --- | --- | --- | +| `ADMIN_PASSWORD` | *(empty string)* | No, but leaving it unset is a real security gap | Read directly by the `create_default_admin` management command. An empty value doesn't mean "no password" — the command falls back to the literal password `admin` and prints a warning. `create_default_admin` runs automatically on every Docker container start (`docker/backend/entrypoint.sh`, right after `migrate`), so an unset `ADMIN_PASSWORD` in a real deployment means the bootstrap Django superuser's password is the literal string `admin` until someone changes it. See [Management commands](management-commands.md#create_default_admin). | diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md new file mode 100644 index 00000000..d9572d21 --- /dev/null +++ b/docs/reference/glossary.md @@ -0,0 +1,126 @@ +# Glossary + +Terms used throughout this documentation that are either specific to BottleCRM's own model, or +that sound generic but resolve to a particular model or field in this codebase. Each entry links to +the page that covers the concept in full; this page is the short definition, not the whole story. + +## Terms + +### Organization + +`Org` (`backend/common/models.py`, db table `organization`) — the tenant boundary. Every org-scoped +record carries an `org` foreign key to this model, either declared directly or inherited from +`BaseOrgModel`, and Row-Level Security enforces that a request in one organization's context never +returns another organization's rows. Carries a `default_currency`/`default_country` locale pair, the +[organization API key](#organization-api-key), and — once a vertical pack has been applied — +`vertical` and `terminology` fields that are **descriptive only**: the model's own comment states +they are never used in a permission check or a queryset filter. + +### Profile + +`Profile` (`backend/common/models.py`) — a user's membership in one organization: a `(user, org)` +pair, unique together, carrying that membership's role (`ADMIN`/`USER`, from `ROLES`), +`is_organization_admin`, `has_sales_access`/`has_marketing_access`, and `is_active`. One `User` can +hold a separate `Profile` — with a separate role — in each organization they belong to. +`request.profile`, set by the `GetProfileAndOrg` middleware from the JWT on every request, is what +every permission check and org filter in this codebase is written against, never `request.user` +directly. See [Permissions and roles](../architecture/permissions-and-roles.md). + +### Tenant + +Not a distinct model or field in this codebase; the general term this documentation (and the wider +industry) uses for "one organization's data, isolated from every other organization's." In +BottleCRM, an organization *is* the tenant boundary — see +[Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md). + +### RLS context + +`app.current_org` — the PostgreSQL session variable Row-Level Security policies read to decide +which rows a query is allowed to see. `CONTEXT_VARIABLE` in `backend/common/rls/__init__.py` names +it; the `RequireOrgContext` middleware (`backend/common/middleware/rls_context.py`) sets it once per +request, via `SELECT set_config('app.current_org', , false)`, and clears it afterward. The +org id it sets comes from `request.org`, one hop removed from `request.profile.org`: the earlier +`GetProfileAndOrg` middleware (`backend/common/middleware/get_company.py`) is what actually resolves +the org — from the JWT, a personal access token, or an organization API key, depending on which +authentication path the request took — and assigns it to `request.org` directly (as well as setting +`request.profile`); `RequireOrgContext` only reads `request.org`, not `request.profile.org`, when +deciding what to set. A request with no context set sees zero rows from every RLS-protected table — +the fail-safe every isolation policy's `NULLIF(current_setting(...), '')` clause is built around. +See [Multi-tenancy and RLS](../architecture/multi-tenancy-and-rls.md). + +### Vertical pack + +A small, declarative JSON manifest (`backend/packs/*.json`, validated by +`backend/common/packs/schema.py`) that shapes an *already-existing* organization toward one +industry: a starter pipeline (with stages) for leads, cases, and tasks; custom field definitions; +tags; products; terminology overrides (for example relabeling "Lead" as "Enquiry"); and a short list +of named sample records that reference each other within the manifest. Applying a pack is a +one-time, idempotent action, distinct from `seed_data` — see +[Management commands](management-commands.md#seed_data) — which generates a large randomized +dataset instead of a curated one. See +[Demo data and packs](../getting-started/demo-data.md#vertical-packs). + +### Portal token + +`PortalAccessToken` (`backend/common/models.py`) — a token minted for an anonymous, unauthenticated +visitor (someone following a shared invoice/estimate link, or answering a CSAT survey) to reach +exactly one record without signing in. Deliberately absent from `ORG_SCOPED_TABLES` and carrying no +RLS policy of its own, because a portal request has no `request.profile` to derive an org context +from in the first place: the table instead maps `sha256(url_token)` to an `org_id` directly, so the +public view can hash the token it was handed, resolve the org, set the RLS context, and only then +query the actual resource. See +[Multi-tenancy and RLS: Portal tokens](../architecture/multi-tenancy-and-rls.md#portal-tokens). + +### Personal access token + +`bcrm_pat_…` (`PersonalAccessToken`, `backend/common/models.py`) — a token a signed-in user creates +for themselves (`POST /api/profile/tokens/`) to authenticate scripts, the MCP server, or other +programmatic access without a browser session. It authenticates as the profile that created it and +inherits that profile's role and org in full; its `scopes` field is stored but not enforced. +Deactivating the owning profile invalidates the token immediately — `resolve_valid_pat` checks +`profile.is_active` on every use — with no separate revocation step required. See +[Tokens and API keys: Personal access tokens](../api/tokens-and-api-keys.md#personal-access-tokens). + +### Organization API key + +`Org.api_key` — a single key per organization, not per user, managed through `GET`/`POST +/api/org/api-key/`, admin-only in both directions. A request bearing it in the `Token` header +authenticates as the organization's first active `ADMIN` profile rather than any specific person, so +a leaked key is equivalent to a leaked admin session — not a leaked personal access token, which +always authenticates as one specific individual. See +[Tokens and API keys: Organization API keys](../api/tokens-and-api-keys.md#organization-api-keys). + +### Stage versus status + +Two different kinds of field that sound alike but aren't. `status` (`Lead.status`, `Case.status`, +`Task.status`) is a plain `CharField` over a fixed set of choices — three separate tuples, not one +shared definition: `LEAD_STATUS` and `STATUS_CHOICE`, defined in `backend/common/utils.py`, back +`Lead.status` and `Case.status` respectively, while `Task.STATUS_CHOICES` is defined inline on the +`Task` model itself in `backend/tasks/models.py`. Each one is still fixed and identical across every +organization — that part of the "status vs. stage" distinction holds for all three. `stage`, on those +same three +models, is a nullable foreign key to a per-org `LeadStage`/`CaseStage`/`TaskStage` row, itself +belonging to a `LeadPipeline`/`CasePipeline`/`TaskPipeline` (see [Pipeline](#pipeline)) — each +organization can define its own Kanban columns, and a stage's `maps_to_status` field is what keeps +the fixed `status` in sync when a record moves between stages. `Opportunity.stage`, despite sharing +the field name, is the *fixed* kind: a plain `CharField` over `common.utils.STAGES` (`PROSPECTING` +… `CLOSED_LOST`), the same six values for every organization, with no configurable pipeline behind +it and no separate `status` field at all. See +[Data model: Conventions](../architecture/data-model.md#conventions) for the full explanation this +entry summarizes. + +### Pipeline + +`LeadPipeline`, `CasePipeline`, `TaskPipeline` — an org-owned, named container of ordered `Stage` +rows for one entity type (see [Stage versus status](#stage-versus-status)). An organization can +define more than one pipeline for the same entity type — each model's own docstring gives an +example set of names (Inbound/Outbound/Enterprise for leads, Support/Engineering/Billing for cases, +Development/Support/Marketing for tasks) — and *at most* one per organization may be marked +`is_default`, enforced by a `UniqueConstraint` on `org` scoped to `is_default=True`; nothing requires +that one exist. The field's own `help_text` says a default pipeline is "where new [records] without +explicit pipeline go", but that's a statement of intent, not a description of running code: nothing +in this codebase currently reads `is_default` to route a new `Lead`, `Case`, or `Task` onto a +pipeline. A record's `stage` foreign key is simply `NULL` until something sets it explicitly, which +its own `help_text` calls "use status-based kanban" — the fixed `status` field, not a pipeline stage, +is what a newly created record actually falls back to. `Opportunity` has no equivalent pipeline +model: its stage list is the fixed set above, not org-configurable. diff --git a/docs/reference/management-commands.md b/docs/reference/management-commands.md new file mode 100644 index 00000000..ca5177b1 --- /dev/null +++ b/docs/reference/management-commands.md @@ -0,0 +1,152 @@ +# Management commands + +Every command below is BottleCRM's own — the entire `common/management/commands/` package holds +exactly these five files. Run `uv run python manage.py help` from `backend/` and everything +outside the `[common]` section comes from Django itself or an installed third-party package +(`django`, `auth`, `contenttypes`, `sessions`, `staticfiles`, `django_ses`, `drf_spectacular`, +`rest_framework`, `token_blacklist`) — none of it is project-specific, so it isn't repeated here. +Run any command from `backend/` with `uv run python manage.py ` (or, against the Docker +stack, `docker compose exec backend python manage.py `). + +## manage_rls + +Read-only diagnostics for Row-Level Security. None of its three flags modify the database — RLS +policies are enabled and disabled by Django migrations, calling `get_enable_policy_sql()`, never by +this command (the module's own docstring says so explicitly). It checks the tables listed in +`ORG_SCOPED_TABLES` (`common/rls/__init__.py`), which as of this writing has **61 entries**. The +repository's `CLAUDE.md` still says 24; that figure is out of date, and the count is deliberately +not repeated anywhere outside `ORG_SCOPED_TABLES` itself so it cannot drift again. + +```bash +uv run python manage.py manage_rls --status +uv run python manage.py manage_rls --test +uv run python manage.py manage_rls --verify-user +``` + +| Flag | What it does | +| --- | --- | +| `--status` | For every table in `ORG_SCOPED_TABLES`, queries `pg_class.relrowsecurity`/`relforcerowsecurity` and prints `ENABLED (forced)`, `disabled`, or `TABLE NOT FOUND`, plus whether the connected role is a superuser. This is also the **default** — running `manage_rls` with no flags at all runs `--status`. | +| `--test` | Picks two organizations (preferring ones that actually have `Lead` rows), sets the RLS context to each in turn and counts visible leads, then sets an empty context and counts again. Zero rows with no context and nonzero with a real context means isolation is working; nonzero rows with no context is a warning, not a failure, since some policies deliberately allow an empty context. | +| `--verify-user` | Queries `pg_user` for `usesuper`/`usecreatedb` on the connected role and prints an explicit `WARNING: Superusers bypass RLS!` with remediation SQL if the role is a superuser. | + +This only covers what the command itself does. For why a superuser role defeats every policy +silently, how to create a correctly-restricted role, and what each flag's output actually proves, +see [PostgreSQL and RLS](../self-hosting/postgresql-and-rls.md#verifying-isolation). + +## seed_data + +Generates a large, randomized dataset — accounts, contacts, leads, opportunities, cases, tasks, +invoices and more — for one or more organizations, entirely from curated fictional name/company +pools and reserved demo domains (`example.com`/`.example`) so nothing it creates resembles a real +person or business. The first organization created (`--orgs` index 0) is always named +`MicroPyramid`, specifically so local-dev workflows have a stable org name to sign in against with +`devlogin --org MicroPyramid` (see below). For the default, single-org run, re-running the command +with the same `--email` reuses that existing user, org, and profile rather than creating duplicates +— `create_org` does an `Org.objects.get_or_create(name="MicroPyramid", ...)`. That reuse guarantee +is specific to index 0: any organization beyond the first (`--orgs 2` or higher) gets a name drawn +randomly from a company-name pool each run, so re-running with `--orgs 2` typically accumulates +additional organizations rather than reusing the second one from the previous run. + +```bash +uv run python manage.py seed_data --email you@example.com +uv run python manage.py seed_data --email you@example.com --orgs 2 --leads 100 --seed 42 +uv run python manage.py seed_data --email you@example.com --clear --no-input +``` + +| Flag | Default | What it does | +| --- | --- | --- | +| `--email` | `aswin.1231@gmail.com` | Email of the admin user to create (or reuse) as the seeded org's admin profile. | +| `--orgs` | `1` | Number of organizations to create. | +| `--users-per-org` | `3` | Additional non-admin users created per org. | +| `--leads` | `20` | Leads per org. | +| `--accounts` | `10` | Accounts per org. | +| `--contacts` | `15` | Contacts per org. | +| `--opportunities` | `10` | Opportunities per org. | +| `--cases` | `5` | Cases per org. | +| `--tasks` | `10` | Tasks per org. | +| `--goals` | `8` | Sales goals per org. | +| `--teams` | `2` | Teams per org. | +| `--tags` | `5` | Tags per org. | +| `--products` | `20` | Products per org. | +| `--invoices` | `50` | Invoices per org. | +| `--estimates` | `15` | Estimates per org. | +| `--recurring-invoices` | `5` | Recurring invoices per org. | +| `--invoice-templates` | `3` | Invoice templates per org. | +| `--currency` | `USD` | Default org currency; restricted to `CURRENCY_CODES` choices. | +| `--country` | `US` | Default org country code; also selects the Faker locale used for generated names/addresses (falls back to `en_US` for an unmapped code). | +| `--seed` | none | Random seed, for a reproducible run. | +| `--password` | `testpass123` | Password set on newly created users. | +| `--clear` | off (flag) | Deletes existing CRM data before seeding — **not** users, orgs, or profiles. Prompts for confirmation unless `--no-input` is also given. This is a separate, broader operation from a vertical pack's own sample-data clearing (see [Demo data and packs](../getting-started/demo-data.md#clearing-sample-data)) and isn't scoped to pack-created records specifically. | +| `--no-input` | off (flag) | Skips the `--clear` confirmation prompt. | + +See [Demo data and packs](../getting-started/demo-data.md) for the narrative walkthrough, including +what a full seeded org looks like and how vertical packs layer on top of it. + +## devlogin + +Mints a JWT access/refresh pair for an existing user directly from the command line — no OAuth +provider, no outbound email, no browser round-trip. **Refuses to run unless `settings.DEBUG` is +`True`**, raising a `CommandError` as the very first thing `handle()` does — that means it prints an +error and exits non-zero in any environment where `DEBUG=False`, not that it silently does nothing. + +```bash +uv run python manage.py devlogin --org +uv run python manage.py devlogin --create +``` + +| Argument | What it does | +| --- | --- | +| `email` (positional, required) | The user to mint a token for. | +| `--org` | Optional org name or UUID. **Resolves id first, then name**: it tries `Org.objects.get(id=org_arg)` first, and only falls back to matching on `Org.name` if that lookup fails (not found, or `org_arg` isn't a valid UUID at all). More than one org sharing that name is refused outright rather than guessed at — pass an id instead. The target user must already have an **active** `Profile` in the resolved org — this command does not create one, and raises a `CommandError` naming the missing user/org pair if none exists. | +| `--create` | Creates the user (with a random, discarded password) if no user with that email exists yet. This only creates the `User` row — it has no effect on the `Profile` requirement above; a freshly created user still needs a `Profile` in the target org before `--org` will succeed for them. | + +On success it prints an access token and a refresh token, the org's UUID if `--org` was given (a +token minted this way already carries the `org_id` claim, so no follow-up org-switch call is +needed), and a `localStorage.setItem(...)` snippet for a browser console — that snippet alone does +**not** sign you into the SvelteKit app itself; see +[Using the tokens in the browser](../getting-started/first-sign-in.md#using-the-tokens-in-the-browser) +for the cookies `hooks.server.js` actually checks. See +[First sign-in: Local development](../getting-started/first-sign-in.md#local-development-devlogin) +for the full walkthrough, including what happens with no `--org` at all. + +## create_default_admin + +Creates a Django superuser, but only if none already exists — `User.objects.filter(is_superuser= +True).exists()` short-circuits the whole command to a no-op if one is found, so it's safe to run on +every startup. It takes no arguments at all (no `add_arguments` override). It reads `ADMIN_EMAIL` +and `ADMIN_PASSWORD` directly with `os.environ.get`, independently of `crm/settings.py` (`ADMIN_EMAIL` +happens to share a name and a default with the copy `settings.py` also reads; `ADMIN_PASSWORD` is +read nowhere else). **If `ADMIN_PASSWORD` is unset or empty, the command falls back to the literal +password `admin`** and prints a warning — it does not refuse to create the account. This command +runs automatically on every Docker container start (`docker/backend/entrypoint.sh`, immediately +after `migrate`), so in the Docker quick start it never needs to be invoked directly, and an unset +`ADMIN_PASSWORD` in a real deployment means the bootstrap superuser's password really is `admin` +until it's changed by hand. + +```bash +uv run python manage.py create_default_admin +``` + +## audit_org_fields + +A one-time Phase 1 multi-tenancy hardening tool, not a general or self-updating auditor: it checks +a **hardcoded** pair of Python lists inside the command itself — `models_with_nullable_org` +(currently just `common.Address` and `common.Tags`) and `models_with_required_org` (thirteen named +models) — not every org-scoped model that exists today. A model added to the codebase since this +command was last edited is invisible to it either way. + +```bash +uv run python manage.py audit_org_fields --check +uv run python manage.py audit_org_fields --fix +uv run python manage.py audit_org_fields --verbose +``` + +| Flag | What it does | +| --- | --- | +| `--check` | Accepted by the argument parser but **never read in `handle()`** — passing it or omitting it produces identical output. The audit itself always runs; there's no way to suppress it short of not invoking the command. | +| `--fix` | For `common.Address` rows with a null `org`, attempts to backfill it from a related `Profile`. For `common.Tags`, `--fix` does **not** fix anything — it prints a message asking for manual review instead. For any other model that turns up with a null `org`, it also just prints "Automatic fix not implemented." | +| `--verbose` | Two effects, one per pass: in the nullable-org pass it prints each model's field configuration (`null`, `blank`, `related_query_name`); in the required-org pass it's what makes a "Model not found" warning print at all — without `--verbose`, a `LookupError` there is swallowed silently. | + +Output uses ✅/❌/⚠️ markers per model and ends with a summary and a suggested next-steps block +(create a migration, run `makemigrations`/`migrate`) — it never modifies the schema itself, only +data, and only when `--fix` is passed. diff --git a/docs/self-hosting/backups-and-upgrades.md b/docs/self-hosting/backups-and-upgrades.md new file mode 100644 index 00000000..c203b379 --- /dev/null +++ b/docs/self-hosting/backups-and-upgrades.md @@ -0,0 +1,96 @@ +# Backups and upgrades + +This page covers what actually needs backing up in a self-hosted BottleCRM instance, and what +"upgrading" means for the Docker Compose setup versus a manual install. + +## What to back up + +Two things hold state that nothing else can regenerate: **the PostgreSQL database** and **the media +directory**. Everything else — the application code, static assets (`collectstatic` output), +installed dependencies, the Redis broker/result store — is either checked into version control, +rebuilt from it, or disposable. Redis in particular is not a backup concern: it's the Celery broker +and result backend (`CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND`), not a store of business data — an +in-flight task queue lost on restart means a retry, not lost records, since the records themselves +live in PostgreSQL. + +## Backing up PostgreSQL + +Every org, every record, every RLS-protected row lives in the database configured by `DBNAME`/ +`DBUSER`/`DBHOST`/`DBPORT` (see [Environment variables](environment-variables.md#database)). A plain +`pg_dump` is enough for a logical backup: + +```bash +pg_dump -h -p -U -d -F c -f backup.dump +``` + +Under Docker Compose, run it against the `db` service: + +```bash +docker compose exec db pg_dump -U postgres -d crm_db -F c -f /tmp/backup.dump +docker compose cp db:/tmp/backup.dump ./backup.dump +``` + +`docker-compose.yml` also declares `postgres_data` as a named volume mounted at +`/var/lib/postgresql/data` on the `db` service — this is the actual on-disk database, and is what +must survive a `docker compose down` (which removes containers but leaves named volumes intact) or a +rebuild. Only `docker compose down -v`, or removing the volume explicitly, deletes it — see +[Docker](docker.md#persisting-data). + +## Media files + +`MEDIA_ROOT` depends on which settings module is active. With `ENV_TYPE=dev` (the default), +`crm/settings.py` sets `MEDIA_ROOT` to a local `media/` directory under `BASE_DIR`, and uploaded +files (invoice attachments, case attachments, and similar) land on local disk. Under Docker Compose +this resolves to `/app/media` inside the `backend` container, which — because `/app` is the +bind-mounted `./backend` directory, not a named volume — means uploads land in `backend/media/` on +the host automatically, but are **not** covered by the `postgres_data` volume's guarantees. Back up +`backend/media/` (or your equivalent local media path in a manual install) alongside the database. + +With `ENV_TYPE=prod`, `backend/crm/server_settings.py` switches `DEFAULT_FILE_STORAGE` to +`storages.backends.s3boto3.S3Boto3Storage` and media is written to the S3 bucket named by +`AWS_BUCKET_NAME` instead of local disk — in that configuration, back up the bucket using your normal +AWS backup practice (versioning, replication, or a separate backup job) rather than a filesystem +backup. + +## Upgrading + +Under Docker Compose, `docker/backend/entrypoint.sh` runs `manage.py migrate --noinput`, +`manage.py create_default_admin` and `manage.py collectstatic --noinput` on **every** container +start, not only the first — so pulling new source and rebuilding is the entire upgrade procedure: + +```bash +git pull +docker compose up --build +``` + +There is no separate migration step to remember; the schema and static assets are brought up to +date automatically on restart. Back up `postgres_data` and `backend/media/`, per the sections above, +before upgrading across any change you're not confident is backward compatible with your existing +data — nothing in this compose setup takes an automatic backup for you. + +For a manual install (no Docker), the equivalent is: pull the new source, `uv sync` to update +dependencies, then run migrations and `collectstatic` yourself before restarting the process (see +[Migrations](#migrations) below and [Production deployment](production-deploy.md#before-you-start)). + +## Migrations + +`manage.py migrate` applies schema changes and, for the RLS-related migrations specifically, +re-applies the Row-Level Security policies described in +[PostgreSQL and RLS](postgresql-and-rls.md#enabling-policies) — RLS is enabled and disabled by +migrations, not by any separate command. Those migrations run with `atomic = False` (the comment +next to it, repeated across every migration that calls `get_enable_policy_sql`, is explicit: "RLS +policy creation can't run inside an atomic block"), and `get_enable_policy_sql()` itself issues +`DROP POLICY IF EXISTS` before each `CREATE POLICY`, so re-running `migrate` after an interrupted run +is safe rather than something that fails on a duplicate policy. + +This project's own CI enforces that no model change ships without a matching migration: +`.github/workflows/tests.yml` runs + +```bash +uv run python manage.py makemigrations --check --dry-run +``` + +as a dedicated step before the test suite. If you fork this project and add or change a model, run +`manage.py makemigrations` yourself and commit the result. The check names the problem directly; +without it you would find out indirectly, from a test or a request failing against a column the +database does not have. diff --git a/docs/self-hosting/docker.md b/docs/self-hosting/docker.md new file mode 100644 index 00000000..a9b39bfd --- /dev/null +++ b/docs/self-hosting/docker.md @@ -0,0 +1,114 @@ +# Docker + +`docker-compose.yml`, at the repository root, runs the full stack — database, cache/broker, +API, background workers and frontend — as six services from one file. This page documents that +file service by service. For the fastest path to a running instance with demo data loaded, see +[Docker quick start](../getting-started/docker-quick-start.md); this page instead covers what to +configure, how data is persisted, and how to upgrade an instance you intend to keep running. + +**This compose file is a development setup, not a hardened production one.** Its `backend` +service bind-mounts the repository (`./backend:/app`) and its entrypoint +(`docker/backend/entrypoint.sh`) ends by running `python manage.py runserver 0.0.0.0:8000` — the +Django development server, not Gunicorn. `.env.docker` also ships `DEBUG=True`. Running it as-is +gets every service talking to each other correctly; it does not by itself satisfy the +requirements in [Production deployment](production-deploy.md). + +## The services + +`docker-compose.yml` defines: + +| Service | Image / build | Host port | Purpose | +|---|---|---|---| +| `db` | `postgres:16-alpine` | `5432` | The PostgreSQL database, `crm_db`. Row-Level Security policies applied by Django migrations rely on this being real PostgreSQL — see [PostgreSQL and RLS](postgresql-and-rls.md). | +| `redis` | `redis:7-alpine` | `6379` | Celery broker and result backend (`CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` both point here). | +| `backend` | built from the repo-root `Dockerfile` | `8000` | The Django API, served via `manage.py runserver` in this compose file (see the warning above). | +| `celery-worker` | same image as `backend` | — (no published port) | Runs `celery -A crm worker --loglevel=info`; executes background tasks such as outbound email and RLS-context-aware jobs in `backend/common/tasks.py`. | +| `celery-beat` | same image as `backend` | — (no published port) | Runs `celery -A crm beat --loglevel=info`; fires the periodic tasks registered in `backend/crm/celery.py` (recurring invoices, overdue/expiry checks, SLA scanning, cleanup jobs) on their schedules. | +| `frontend` | built from `frontend/Dockerfile` | `5173` | The SvelteKit app, served via `pnpm dev --host 0.0.0.0` — also a dev server, not a production build. | + +`backend`, `celery-worker` and `celery-beat` each declare `depends_on: db (service_healthy), redis +(service_healthy)`, using the `pg_isready` and `redis-cli ping` healthchecks defined on those two +services, so they wait for both to actually accept connections rather than just for their +containers to start. + +## Configuration + +Every service loads two env files, in order: `.env.docker` (checked into the repository, working +defaults) and then `.env.docker.local` (optional, gitignored, `required: false` — create it next +to `.env.docker` to override specific values without touching the tracked file; values there win). + +The variables `.env.docker` sets: `SECRET_KEY` (an insecure placeholder — see +[Production deployment](production-deploy.md) before this instance is anything but local), +`DEBUG=True`, `ENV_TYPE=dev`, `ALLOWED_HOSTS=localhost,127.0.0.1,backend`, `DOMAIN_NAME` and +`SWAGGER_ROOT_URL` (both `http://localhost:8000`), the `DBNAME`/`DBUSER`/`DBPASSWORD`/`DBHOST`/ +`DBPORT` the `backend` and Celery containers use to reach `db`, `POSTGRES_DB`/`POSTGRES_USER`/ +`POSTGRES_PASSWORD` (used only by the `db` service itself, to create the database and its own +superuser role — see [PostgreSQL and RLS](postgresql-and-rls.md) for why the app never connects +as this role), `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` (both pointing at `redis`), +`DEFAULT_FROM_EMAIL`/`ADMIN_EMAIL`/`ADMIN_PASSWORD`, `CORS_ALLOW_ALL=True`, +`GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/`GOOGLE_REDIRECT_URI` (blank — Google sign-in stays +disabled until set), and `PUBLIC_DJANGO_API_URL=http://localhost:8000` for the frontend build. + +## Running it + +From the repository root (the directory containing `docker-compose.yml` and `Dockerfile`): + +```bash +docker compose up --build +``` + +This requires the Compose v2 plugin (the `docker compose` subcommand, not the standalone +`docker-compose` binary). On first boot, `docker/postgres/init-rls-user.sql` runs automatically +via PostgreSQL's `/docker-entrypoint-initdb.d/` mechanism and creates the non-superuser `crm_user` +role that `backend` actually connects as. `docker/backend/entrypoint.sh` then waits for +PostgreSQL to accept connections, runs `manage.py migrate --noinput`, runs +`manage.py create_default_admin`, runs `manage.py collectstatic --noinput`, and finally starts the +dev server — every one of those steps runs again on every container restart, not just the first. + +Run any one-off management command inside the running `backend` container with `docker compose +exec`, for example: + +```bash +docker compose exec backend python manage.py migrate +docker compose exec backend python manage.py manage_rls --status +``` + +## Persisting data + +`docker-compose.yml` declares two named volumes: + +| Volume | Mounted at | Holds | +|---|---|---| +| `postgres_data` | `/var/lib/postgresql/data` on `db` | The actual database: every org, record, and RLS-protected row. This is the volume that must survive a rebuild. | +| `frontend_node_modules` | `/app/node_modules` on `frontend` | Installed frontend dependencies — a build cache, not user data. Safe to delete; `pnpm install` recreates it. | + +Everything else is a bind mount of source code from the host, not a Docker-managed volume: +`./backend:/app` on `backend`/`celery-worker`/`celery-beat`, and `./frontend:/app` on `frontend`. +One consequence worth knowing: with `ENV_TYPE=dev` (the shipped default), Django's `MEDIA_ROOT` is +`BASE_DIR/media`, which resolves inside the container to `/app/media` — and because `/app` is the +bind-mounted `./backend` directory, uploaded files (invoice attachments, case attachments, and so +on) land in `backend/media/` on the host automatically. They persist across container rebuilds for +the same reason the source code does, without needing a dedicated named volume — but they are +**not** covered by a `postgres_data`-style volume, so back up `backend/media/` alongside the +database if you rely on uploaded files. + +`docker compose down` removes containers and the default network but leaves named volumes intact; +only `docker compose down -v`, or removing `postgres_data`/`frontend_node_modules` explicitly, +deletes them. + +## Upgrading + +To move to a newer version of the application: pull the new source, then rebuild and restart: + +```bash +docker compose up --build +``` + +Because `entrypoint.sh` runs `manage.py migrate --noinput` on every container start (not only the +first), rebuilding and restarting is sufficient to bring the schema up to date — there is no +separate migration step to remember. `collectstatic` also reruns on every start, so updated static +assets are picked up automatically. + +Back up the `postgres_data` volume (and `backend/media/`, per the note above) before upgrading +across any change you're not certain is backward compatible with your existing data — nothing in +this compose setup takes an automatic backup for you. diff --git a/docs/self-hosting/email-and-celery.md b/docs/self-hosting/email-and-celery.md new file mode 100644 index 00000000..5bfee504 --- /dev/null +++ b/docs/self-hosting/email-and-celery.md @@ -0,0 +1,115 @@ +# Email and Celery + +BottleCRM sends email (welcome messages, magic-link sign-in, comment mentions) and runs background +and scheduled work (recurring invoices, SLA scanning, token cleanup) through Django's email +framework and Celery, respectively. This page covers how to configure both and the one rule every +background task that touches tenant data has to follow. + +## Email backend + +`EMAIL_BACKEND` in `backend/crm/settings.py` defaults to +`django.core.mail.backends.console.EmailBackend` — outbound mail is printed to the process's stdout +rather than delivered anywhere. `.env.example` sets `EMAIL_BACKEND` to that console backend +explicitly; `.env.docker` doesn't set `EMAIL_BACKEND` at all (only a comment noting the console +backend is what's active for local development), so it relies on the same default rather than +setting it. To send real email, set `EMAIL_BACKEND` to a working Django email backend, most +commonly `django_ses.SESBackend` (see [Amazon SES](#amazon-ses) below); `django_ses` is always in +`INSTALLED_APPS`, so the package is available regardless of which backend you choose. + +There is a second, coarser way `EMAIL_BACKEND` gets set to SES: if you set `ENV_TYPE=prod`, +`backend/crm/server_settings.py` is imported and hardcodes `EMAIL_BACKEND = "django_ses.SESBackend"` +unconditionally, overriding whatever `EMAIL_BACKEND` you set directly. `ENV_TYPE=prod` also requires +`AWS_BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SES_REGION_NAME`, +`AWS_SES_REGION_ENDPOINT` and `SENTRY_DSN` to already be set (a plain `KeyError` at import time if +any is missing) and hardcodes `SESSION_COOKIE_DOMAIN = ".bottlecrm.io"` — see +[Production deployment](production-deploy.md#static-files) for the full consequences. If you only +want SES for email and don't want the S3/Sentry/cookie-domain side effects that come with +`ENV_TYPE=prod`, set `EMAIL_BACKEND=django_ses.SESBackend` directly instead and leave `ENV_TYPE=dev`. + +`DEFAULT_FROM_EMAIL` (default `noreply@localhost`) is the sender address used on every outbound +message; `ADMIN_EMAIL` (default `admin@localhost`) is separate and is what +`manage.py create_default_admin` uses for the seeded admin account, not a mail setting. + +## Amazon SES + +Once `EMAIL_BACKEND` resolves to `django_ses.SESBackend` — whichever of the two paths above got you +there — `crm/settings.py` reads `AWS_SES_REGION_NAME` (default `ap-south-1`) and +`AWS_SES_REGION_ENDPOINT` (default `email.{AWS_SES_REGION_NAME}.amazonaws.com`, computed from +whatever you set for the region). AWS credentials are read implicitly by `django-ses`/`boto3` from +`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` if you set them; if you don't, `boto3` falls back to +IAM role credentials, which is the intended path when running inside AWS (EC2/ECS instance role) +rather than with long-lived access keys in the environment. + +The Celery tasks that actually send mail — `send_welcome_email` and `send_magic_link_email` in +`backend/common/tasks.py` — catch `botocore.exceptions.ClientError` around the `msg.send()` call and +log the failure rather than letting the task raise, specifically because SES can reject a send (an +unverified sender identity, a suppressed recipient address, being outside the SES sandbox) in ways +that are the mail provider's problem, not a bug in the task. Check the worker's logs, not the Celery +task result, if an expected email never arrives. + +## Broker and result backend + +`CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` both default to `redis://localhost:6379/0` in +`crm/settings.py`, and both `.env.example` and `.env.docker` leave them at that default (Docker +Compose's value points at the `redis` service hostname instead of `localhost`). Both need a reachable +Redis instance — `backend/crm/celery.py` constructs `Celery("crm")` and calls +`app.config_from_object("django.conf:settings", namespace="CELERY")`, so these two Django settings +are what Celery actually connects with; there's no separate Celery-specific config file. + +## Running a worker + +```bash +cd backend +uv run celery -A crm worker --loglevel=INFO +``` + +This process executes tasks queued by the API — sending the emails above, mention notifications, +team-membership propagation, and more. A separate `celery beat` process is required for anything on +a schedule: `backend/crm/celery.py` registers `app.conf.beat_schedule` with ten periodic entries, +including recurring-invoice generation and overdue/expired-estimate checks (daily), SLA breach +scanning for cases (every 5 minutes), stale-timer cleanup (every 30 minutes), and nightly cleanup of +read notifications and expired refresh-token records: + +```bash +cd backend +uv run celery -A crm beat --loglevel=INFO +``` + +Neither process is required for the API itself to answer requests — only for background and +scheduled work to actually run. `docker-compose.yml` runs both as their own services, +`celery-worker` and `celery-beat`, alongside `backend`. + +## RLS in background tasks + +Celery workers never go through Django's request middleware, so nothing sets the PostgreSQL session +variable `app.current_org` for them the way `RequireOrgContext` does for an HTTP request — see +[PostgreSQL and RLS](postgresql-and-rls.md#verifying-isolation) for the full mechanism. Every task in +`backend/common/tasks.py` that queries an org-scoped table calls `set_rls_context(org_id)`, also +defined in that module, as its first line: + +```python +from common.tasks import set_rls_context + +@shared_task +def my_task(data_id, org_id): + set_rls_context(org_id) # required before any org-scoped query + ... +``` + +Skip this and the task runs with whatever RLS context the worker's underlying database connection +happens to have — on a fresh connection, that's empty, which (per the fail-safe design described in +[PostgreSQL and RLS](postgresql-and-rls.md#why-rls)) means the query returns zero rows rather than +another org's rows, but it is still a bug: a task that's supposed to act on real data silently does +nothing. `flush_expired_refresh_tokens` is a legitimate case where no context is needed at all: it +operates on simplejwt's `OutstandingToken` table, which isn't in `ORG_SCOPED_TABLES` and carries no +`org_id` column, so there's no per-org filtering to get right in the first place. + +`purge_read_notifications` is not the same kind of case, despite its own docstring's claim that "RLS +does not need a per-org context here." `notification` **is** in `ORG_SCOPED_TABLES` +(`backend/common/rls/__init__.py`) and gets the same `FORCE ROW LEVEL SECURITY` policy as every other +org-scoped table, and this task never calls `set_rls_context`. On the non-superuser database role +this project requires, it therefore runs with an empty RLS context, and its `DELETE` matches zero +rows — so instead of purging old read notifications across every org, it silently purges nothing at +all. This is exactly the failure mode described above, not an exception to it: a live bug in this +codebase's own scheduled task, worth knowing if you're relying on it to keep the `notification` table +from growing unbounded. diff --git a/docs/self-hosting/environment-variables.md b/docs/self-hosting/environment-variables.md new file mode 100644 index 00000000..c02822e6 --- /dev/null +++ b/docs/self-hosting/environment-variables.md @@ -0,0 +1,80 @@ +# Environment variables + +`backend/crm/settings.py` is read once, at process import time, by every Django entry point — +`manage.py runserver`, `manage.py migrate`, a Gunicorn/Uvicorn worker, a Celery worker, a management +command. This page explains where those values come from and which ones you actually have to set +yourself; the exhaustive per-variable table lives in the Reference section. + +## How configuration is loaded + +`backend/crm/settings.py` calls `load_dotenv()` (from `python-dotenv`) near the top of the module, +before any setting is read, so a `.env` file next to `manage.py` — created by copying +`backend/.env.example`, per [Manual setup](../getting-started/manual-setup.md) — is loaded into the +process environment automatically. `docker-compose.yml` does the equivalent for containers by +listing `.env.docker` and then the optional, gitignored `.env.docker.local` under each service's +`env_file:`, in that order, so values in `.env.docker.local` win. + +Almost every setting in `crm/settings.py` follows the same pattern: `os.environ.get("NAME", +)`, so an unset variable falls back to a working (usually dev-oriented) default rather than +failing. `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, `DBNAME`/`DBUSER`/`DBPASSWORD`/`DBHOST`/`DBPORT`, +`EMAIL_BACKEND`, `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND`, `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/ +`GOOGLE_REDIRECT_URI`, `DOMAIN_NAME`/`FRONTEND_URL`/`SWAGGER_ROOT_URL` are all read this way. A +smaller set of variables has no default at all: `backend/crm/server_settings.py` (imported only when +`ENV_TYPE=prod`, see [Production deployment](production-deploy.md)) reads `AWS_BUCKET_NAME`, +`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SES_REGION_NAME`, `AWS_SES_REGION_ENDPOINT` and +`SENTRY_DSN` with plain `os.environ[...]` — a missing one is a `KeyError` at import time, which means +the process refuses to start at all rather than starting with something half-configured. + +Because all of this runs at import time, not per-request, a changed environment variable only takes +effect the next time the process restarts — editing `.env` while `runserver` is already running does +nothing until you restart it (or, for the Docker Compose setup, until the container restarts, which +happens automatically on `docker compose up --build`). + +## The minimum for a working install + +The values in `.env.example` and `.env.docker` are enough to bring the stack up and use it locally; +you do not need to touch anything else to follow [Docker quick start](../getting-started/docker-quick-start.md) +or [Manual setup](../getting-started/manual-setup.md). Before anyone other than you can reach the +instance, the variables that actually need a real value are: `SECRET_KEY` (a unique secret, not the +shipped `django-insecure-...` placeholder), `DEBUG=False`, `ALLOWED_HOSTS` (your real hostname(s)), +`CSRF_TRUSTED_ORIGINS` (each entry needs a scheme, e.g. `https://crm.example.com`), `DBUSER` pointed +at a non-superuser PostgreSQL role, and `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` pointed at a +Redis instance you control. [Production deployment](production-deploy.md#required-settings) covers +the full list and exactly what each one guards against; [PostgreSQL and RLS](postgresql-and-rls.md) +covers why the database role matters as much as any of the Django settings. + +## Database + +`DATABASES["default"]` in `crm/settings.py` reads `DBNAME` (default `crm_db`), `DBUSER` (default +`postgres`), `DBPASSWORD` (default `postgres`), `DBHOST` (default `localhost`) and `DBPORT` (default +`5432`). The default `DBUSER` is the PostgreSQL superuser account on most installations, and a +superuser silently bypasses every Row-Level Security policy this project relies on for tenant +isolation — see [PostgreSQL and RLS](postgresql-and-rls.md) for how to create and verify a +non-superuser role before pointing these variables at anything but a local, throwaway database. + +## Email + +`EMAIL_BACKEND` defaults to `django.core.mail.backends.console.EmailBackend`, so outbound mail +(welcome emails, magic-link sign-in emails, comment-mention notifications) prints to stdout instead +of being sent anywhere. `.env.example` sets `EMAIL_BACKEND` to that same console backend explicitly; +`.env.docker` doesn't set `EMAIL_BACKEND` at all — only a comment noting the console backend is +what's active — so it's relying on the identical default rather than setting it. `DEFAULT_FROM_EMAIL` +and `ADMIN_EMAIL` default to `noreply@localhost`/`admin@localhost`. Switching to a real mail backend, +and the Amazon SES-specific variables that come with it, is covered in full in +[Email and Celery](email-and-celery.md). + +## Integrations + +`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` and `GOOGLE_REDIRECT_URI` all default to empty strings in +`crm/settings.py`. `.env.docker` ships all three blank; `.env.example` ships `GOOGLE_CLIENT_ID` and +`GOOGLE_CLIENT_SECRET` blank but omits `GOOGLE_REDIRECT_URI` entirely, relying on the same default. +Google sign-in stays off until `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` are set, on both the backend +and the SvelteKit frontend. See [Google OAuth](google-oauth.md) for what each one does, where to get +the values, and why `GOOGLE_REDIRECT_URI` specifically doesn't need to be set even in production. +`SENTRY_DSN` is read (with no default) only inside `server_settings.py`, i.e. only when +`ENV_TYPE=prod`; it's not consulted at all otherwise. + +## Full reference + +Every variable this project reads, its default, and the file that reads it is tabulated in full in +the Reference section. diff --git a/docs/self-hosting/google-oauth.md b/docs/self-hosting/google-oauth.md new file mode 100644 index 00000000..b522c9bd --- /dev/null +++ b/docs/self-hosting/google-oauth.md @@ -0,0 +1,86 @@ +# Google OAuth + +BottleCRM supports signing in with Google from both the web app (an authorization-code exchange +with PKCE) and the mobile app (a Google ID token posted directly to the backend). Both are optional: +the deployment works without them, using [magic-link email sign-in](../getting-started/first-sign-in.md) +instead, until you configure a Google OAuth client. + +## Registering an OAuth client + +Create an OAuth 2.0 Client ID of type "Web application" in the Google Cloud Console. The redirect +URI it needs authorized is built by the frontend's login page +(`frontend/src/routes/(no-layout)/login/+page.server.js`) as `${GOOGLE_LOGIN_DOMAIN}/login` — +`GOOGLE_LOGIN_DOMAIN` is a **frontend** environment variable (`frontend/.env.example` defaults it to +`http://localhost:5173`), so the value to register with Google is `http://localhost:5173/login` for +local development, or `https:///login` in production. There is no separate +redirect registration needed for the mobile flow — it doesn't use a redirect at all (see +[Mobile ID tokens](#mobile-id-tokens) below). + +The frontend needs its own copy of the client ID — `frontend/.env.example` sets `GOOGLE_CLIENT_ID` +(client ID only; the comment in that file is explicit that "secret is on backend") — because it +builds the Google authorization URL client-side (`client_id`, `redirect_uri`, PKCE `code_challenge`, +and a CSRF `state` parameter). The client secret only ever lives on the backend. + +## Backend configuration + +`backend/crm/settings.py` reads three Google-related variables, all defaulting to an empty string: + +```python +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") +GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") +GOOGLE_REDIRECT_URI = os.environ.get("GOOGLE_REDIRECT_URI", "") +``` + +`GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are what the backend sends to Google's own token +endpoint when exchanging an authorization code (see below) and what it uses as the expected audience +when verifying a mobile ID token — leaving either blank means every such exchange or verification +fails, so sign-in with Google is effectively off until both are set. `.env.docker` ships all three +blank with the comment "leave blank to disable"; `.env.example` doesn't include +`GOOGLE_REDIRECT_URI` at all, only `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` under an "optional — +leave blank to disable" comment. That's consistent with the code: `GOOGLE_REDIRECT_URI` is defined +in `settings.py` but not read anywhere else in the backend — the redirect URI actually used in the +token exchange is the one the frontend sends in the request body on each call (see +[The PKCE callback flow](#the-pkce-callback-flow)), not this setting. + +## The PKCE callback flow + +The web flow is the standard OAuth Authorization Code flow with PKCE, split across the frontend and +`GoogleOAuthCallbackView`: + +1. `login/+page.server.js` generates a PKCE `code_verifier`/`code_challenge` pair and a random + `state`, stores the verifier and state in short-lived httpOnly cookies, and redirects the browser + to Google's authorization endpoint with the `code_challenge`. +2. Google redirects back to `/login?code=...&state=...`. The frontend checks the returned `state` + against the cookie (CSRF protection) and reads back the stored `code_verifier`, then makes a + server-to-server call — `POST /api/auth/google/callback/` + (`GoogleOAuthCallbackView`, wired at `backend/common/urls.py:67`) — with `code`, `code_verifier` + and `redirect_uri` in the body. +3. `GoogleOAuthCallbackView` exchanges the code for tokens directly with Google + (`https://oauth2.googleapis.com/token`), sending `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, the + `redirect_uri` and `code_verifier` it received — this is the step that requires the client secret, + which is why it has to happen on the backend rather than in the browser. +4. The view decodes the `id_token` Google returns (its own code comment notes this is safe without a + signature check specifically because the token came directly from Google over HTTPS in the + previous step, not from an untrusted client) and requires `email_verified` to be true before + trusting the email address at all — an unverified address never provisions or signs in to an + account. +5. It gets-or-creates a `User` by email, rejects with 403 if the account is deactivated + (`user.is_active`), and issues BottleCRM's own JWT pair via + `OrgAwareRefreshToken.for_user_and_org(user, None)`. The `None` org means this token carries no + `org_id` claim — the user still has to pick an organization ([Org + switching](../getting-started/first-sign-in.md#choosing-an-organization)) before any org-scoped + request will work. + +## Mobile ID tokens + +The mobile app skips the redirect entirely: Google Sign-In on the device returns a Google-issued ID +token, and the app posts it straight to `POST /api/auth/google/` +(`GoogleIdTokenView`, wired at `backend/common/urls.py:69`) as `{"idToken": "..."}`. Unlike the web +callback above, this view does cryptographically verify the token — `google.oauth2.id_token. +verify_oauth2_token(id_token_str, google_requests.Request(), settings.GOOGLE_CLIENT_ID)` — checking +the signature against Google's public keys and the audience against the backend's own +`GOOGLE_CLIENT_ID`, so a token issued for a different client is rejected. It applies the same +`email_verified` check and inactive-account check as the web flow, then returns `JWTtoken`, +`refresh_token` and the list of organizations the user already has a `Profile` in +(`{"id", "name", "role"}` per org) so the mobile app can present an org picker without a second +round trip. diff --git a/docs/self-hosting/postgresql-and-rls.md b/docs/self-hosting/postgresql-and-rls.md new file mode 100644 index 00000000..458059f4 --- /dev/null +++ b/docs/self-hosting/postgresql-and-rls.md @@ -0,0 +1,231 @@ +# PostgreSQL and RLS + +BottleCRM's multi-tenancy is not just an application-layer convention — every org-scoped table is +protected by PostgreSQL Row-Level Security (RLS), configured centrally in +`backend/common/rls/__init__.py` and enabled by Django migrations. This page explains why that +matters, how to set up the database role RLS depends on, how policies get applied, and how to +verify isolation is actually working rather than assuming it is. + +## Why RLS + +Every RLS-protected table carries an `org_id` column, and PostgreSQL — not just the Django ORM — +refuses to return or accept rows whose `org_id` doesn't match the current session's org context. +That context is the PostgreSQL session variable `app.current_org`, set by application middleware +(see [Verifying isolation](#verifying-isolation) below) from the caller's authenticated org. The +policies in `get_enable_policy_sql()` apply this filter with `USING (org_id::text = (select +NULLIF(current_setting('app.current_org', true), '')))` — so it's enforced even for a raw SQL +query issued outside the ORM, a query in a Django view that forgot its `.filter(org=...)`, or a +bug in application code. This is defense in depth: `Model.objects.filter(org=request.profile.org)` +in application code is still the contract, RLS is the safety net underneath it. + +**The single most important fact in this section: a PostgreSQL superuser role bypasses RLS +entirely, and it does so silently — no error, no log line, nothing.** `manage_rls.py`'s own +`--status` output says as much: `Database user "X" is a SUPERUSER - RLS will be bypassed!` +Every policy still exists and looks correctly configured; a superuser connection simply ignores +them. If your application's database role is a superuser — and the default in +`backend/crm/settings.py` (`DBUSER` defaulting to `postgres`) is exactly that unless you override +it — every RLS policy in your schema is dead weight and tenant isolation depends entirely on +every application code path remembering its own org filter. There is nothing in this codebase +that detects or warns about this at request time; it is only visible if you deliberately run +`manage_rls --verify-user` or `--status`. + +## Creating the application role + +The application must connect as a **non-superuser** PostgreSQL role. Connected as the `postgres` +superuser (or an equivalent admin account): + +```sql +CREATE DATABASE crm_db; +CREATE USER crm_user WITH PASSWORD 'crm_password'; +GRANT ALL PRIVILEGES ON DATABASE crm_db TO crm_user; +\connect crm_db; +GRANT ALL ON SCHEMA public TO crm_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO crm_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO crm_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO crm_user; +``` + +(This mirrors what `docker/postgres/init-rls-user.sql` runs automatically for the Docker Compose +setup — see [Docker](docker.md) — except that script also runs unconditionally, wrapped in a `DO +$$ ... IF NOT EXISTS ...` block so re-running it on an existing database is safe.) + +Granting schema and table privileges (`GRANT ALL ON SCHEMA`, `GRANT ALL ... ON TABLES`) does +**not** weaken RLS — those privileges govern DDL and raw read/write access; only the +`BYPASSRLS` role attribute or superuser status bypasses row-level policies. Do not grant +`BYPASSRLS` to the application role; nothing in this codebase needs it, and there is no +superadmin-style cross-org dashboard in this repository that would justify it. + +Point Django at the new role — `DBNAME`, `DBUSER`, `DBPASSWORD`, `DBHOST`, `DBPORT` in your `.env` +(see [Manual setup](../getting-started/manual-setup.md)) or `.env.docker`/`.env.docker.local` +(see [Docker](docker.md)) — then confirm it landed correctly: + +```bash +cd backend +uv run python manage.py manage_rls --verify-user +``` + +This runs `SELECT usename, usesuper, usecreatedb FROM pg_user WHERE usename = current_user` and +prints `Is superuser: False` (and a success line) when configured correctly, or an explicit +`WARNING: Superusers bypass RLS!` with remediation SQL when it isn't. + +## Enabling policies + +RLS is enabled and disabled **by Django migrations, not by any management command** — +`manage_rls.py`'s own module docstring says so explicitly. Running migrations is what actually +applies policies: + +```bash +cd backend +uv run python manage.py migrate +``` + +The set of tables that get policies is centralized in `ORG_SCOPED_TABLES` in +`backend/common/rls/__init__.py` — at the time of writing this list has 61 entries, spanning core +records (`lead`, `accounts`, `contacts`, `opportunity`, `case`, `task`, `invoice`, ...), supporting +data (`comment`, `attachments`, `document`, `activity`, `tags`, ...), Kanban boards and pipeline +stage tables, invoicing/estimate/recurring-invoice tables, and more. One deliberate exception: +`personal_access_token` is **not** in `ORG_SCOPED_TABLES` — it's looked up by token hash before any +tenant context exists (an "auth-bootstrap" table, the comment in that file calls it, mirroring the +`Org` table itself), and its isolation is enforced instead by explicit `org`/`profile` filters in +`backend/common/views/pat_views.py`. + +For each table, `get_enable_policy_sql(table)` runs: + +```sql +ALTER TABLE "
" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "
" FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS org_isolation ON "
"; +DROP POLICY IF EXISTS org_insert_check ON "
"; + +CREATE POLICY org_isolation ON "
" + FOR ALL + USING (org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))); + +CREATE POLICY org_insert_check ON "
" + FOR INSERT + WITH CHECK (org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))); +``` + +The two `DROP POLICY IF EXISTS` statements are why re-running `manage.py migrate` after an +interrupted run is safe rather than something that fails on a duplicate policy — see +[Troubleshooting](troubleshooting.md#migrations-fail) and +[Backups and upgrades](backups-and-upgrades.md#migrations). + +`FORCE ROW LEVEL SECURITY` matters specifically because the table owner (typically the role that +ran the migrations) would otherwise be exempt from its own table's RLS policies by default — +forcing it closes that gap without requiring a separate, non-owning application role. + +To add RLS to a new org-scoped model: inherit from `BaseOrgModel`, add the table name to +`ORG_SCOPED_TABLES`, and write a migration that calls `get_enable_policy_sql('your_table')` inside +a `RunPython` operation (guarded with `if schema_editor.connection.vendor != 'postgresql': return`, +since SQLite has no concept of row security). + +## Verifying isolation + +`manage_rls` (`backend/common/management/commands/manage_rls.py`) is a diagnostic command with +three flags, each proving something different — none of them modify the database: + +```bash +cd backend +uv run python manage.py manage_rls --status +uv run python manage.py manage_rls --verify-user +uv run python manage.py manage_rls --test +``` + +- **`--status`** proves *which tables have policies applied, and whether the connected role is a + superuser*. It queries `current_user`/`usesuper` from `pg_user`, then for every table in + `ORG_SCOPED_TABLES` checks `pg_class.relrowsecurity`/`relforcerowsecurity` and prints + `ENABLED (forced)`, `disabled`, or `TABLE NOT FOUND`, ending with an `Enabled: N, Disabled: N` + tally. +- **`--verify-user`** proves *the connected database role cannot bypass RLS*. It's the + `--status` command's superuser check in isolation, with explicit remediation SQL printed if the + role turns out to be a superuser. +- **`--test`** proves *isolation actually holds for real data*, not just that policies exist. It + picks up to two orgs that have `lead` rows (falling back to the first two orgs in the database + if none has leads yet, and skipping entirely if fewer than two orgs exist at all), sets + `app.current_org` to each in turn and counts visible leads, then sets the context to an **empty + string** and counts again. Seeing non-zero counts for the two real orgs and zero for the empty + context is the actual proof of isolation the other two flags can't give you. + +**Why the empty-context count matters:** the session variable is `app.current_org`, and it's set +by `RequireOrgContext` (`backend/common/middleware/rls_context.py`, wired into `MIDDLEWARE` in +`backend/crm/settings.py`) from `request.org` — which is itself derived earlier in the same +middleware chain by `GetProfileAndOrg` (`backend/common/middleware/get_company.py`) from the +`org_id` claim inside the caller's signed JWT, validated against an active `Profile` for that +user in that org before it's trusted. (The file also defines a second, older middleware class, +`SetOrgContext`, referenced in that file's own module docstring — but `settings.py` wires +`RequireOrgContext` instead, which additionally rejects unauthenticated/context-less requests +with a 403 rather than silently proceeding without a context; `RequireOrgContext` is the class +actually enforced.) Every policy uses `NULLIF(current_setting('app.current_org', true), '')` — +when no context has been set, `current_setting` returns an empty string, `NULLIF` turns that into +SQL `NULL`, and `org_id::text = NULL` is never true. **No context means zero rows, by design** — +a fail-safe default, not an edge case to special-case around. + +`RequireOrgContext` resets the variable to `''` after every request (`SELECT set_config +('app.current_org', '', false)`), specifically to stop it leaking between requests on pooled +connections. + +**Celery workers never run this middleware.** Background tasks in `backend/common/tasks.py` that +touch org-scoped tables call `set_rls_context(org_id)` — also defined in `common/tasks.py` — as +their first line, before any ORM query: + +```python +from common.tasks import set_rls_context + +@shared_task +def my_task(data_id, org_id): + set_rls_context(org_id) # required before any org-scoped query + ... +``` + +`set_rls_context` is a thin wrapper around the same `set_config('app.current_org', ...)` call the +middleware uses, with an early return on non-PostgreSQL connections (so it's a no-op under the +SQLite test settings). A task that skips this and queries an RLS-protected table runs with +whatever context the underlying connection happened to have — on a fresh worker connection, that's +empty, which (per the design above) returns zero rows rather than the wrong org's rows; but it is +still a bug worth catching, since "silently returns nothing" produces its own class of confusing +failures. Not every task in `common/tasks.py` needs this: `flush_expired_refresh_tokens` operates on +simplejwt's `OutstandingToken`, which has no `org` column and is not in `ORG_SCOPED_TABLES`, so no +context applies to it. + +`purge_read_notifications` is the counter-example, and it is worth studying because it looks like the +same case and is not. Its docstring asserts that RLS needs no per-org context here, but `notification` +*is* in `ORG_SCOPED_TABLES`, so on the non-superuser role this page tells you to use, that task runs +with an empty context and its delete matches nothing. Read notifications are never purged. Take it as +the practical warning: a docstring claiming a task is exempt is not evidence, and the failure is +silent — the task reports success having done nothing at all. + +## Common mistakes + +- **Leaving the default database role as `postgres`.** Nothing enforces a non-superuser role for + you; `backend/crm/settings.py`'s `DATABASES` setting defaults `DBUSER` to `postgres` if the + environment variable is unset. That default is a superuser almost everywhere PostgreSQL is + installed. Run `manage_rls --verify-user` after any environment change, not just once at setup. +- **Trusting a green local/dev run as proof RLS works.** If your development database user is a + superuser (very common — it's the path of least resistance to "just get it running"), every + endpoint will appear to isolate tenants correctly purely because application-level `.filter(org= + ...)` calls happen to be correct, while RLS itself is doing nothing. Only `--verify-user` (or + `--status`) surfaces this; functional testing cannot. +- **Forgetting `set_rls_context(org_id)` in a new Celery task** that queries an org-scoped model — + see [Verifying isolation](#verifying-isolation) above. +- **Adding a new org-scoped model without registering it.** `BaseOrgModel` gives you the `org` + foreign key, but RLS policies only get created for tables listed in `ORG_SCOPED_TABLES` with a + migration that calls `get_enable_policy_sql`. A table can carry real `org_id` data and simply + have no RLS protection if this step is skipped — `manage_rls --status` only reports on tables + that are in that list in the first place, so an unregistered table won't even show up as + "disabled." +- **Assuming the anonymous client-portal endpoints (invoice/estimate links, CSAT surveys) are + either broken or unprotected — they're a deliberate, narrow exception, and testing them locally + can be misleading either way.** `RequireOrgContext.EXEMPT_PATHS` in + `backend/common/middleware/rls_context.py` exempts exactly `/api/public/invoice/`, + `/api/public/estimate/` and `/api/public/csat/` from requiring org context, because these are + genuinely anonymous requests (a customer clicking an emailed link) authorized by a possession + token rather than a JWT — see `backend/docs/PORTAL_RLS.md`. Exemption from *requiring* context + is not the same as *setting* one: `_set_org_context` returns early when `request.org` is `None`, + so these requests run with `app.current_org` empty — which, per the fail-safe design above, + means the `invoice`/`estimate`/`csat_survey` rows the view is trying to read are invisible under + RLS too, until the view itself resolves the org from the token and calls `set_rls_context` + explicitly. This is exactly the kind of thing a superuser dev database masks: with `DBUSER= + postgres`, RLS does nothing, so a locally-broken or locally-fine portal endpoint looks identical. + Test portal endpoints against a real non-superuser role before trusting them. diff --git a/docs/self-hosting/production-deploy.md b/docs/self-hosting/production-deploy.md new file mode 100644 index 00000000..592ab538 --- /dev/null +++ b/docs/self-hosting/production-deploy.md @@ -0,0 +1,118 @@ +# Production deployment + +Neither `docker-compose.yml` nor the repo-root `Dockerfile` builds a production-hardened +deployment out of the box — see the warning in [Docker](docker.md). This page covers what +changes: the process that serves requests, how static files are handled, what sits in front of +Django, and which environment variables the codebase itself treats as security-relevant. It +assumes you already have a working install per [Manual setup](../getting-started/manual-setup.md) +or [Docker](docker.md) and a PostgreSQL role configured per +[PostgreSQL and RLS](postgresql-and-rls.md). + +## Before you start + +There is no bundled production `docker-compose` file, systemd unit, or process manager +configuration in this repository — the `Dockerfile` builds an image (`backend/Dockerfile` puts +`uv`-managed dependencies and the venv's `bin/` on `PATH`, so `gunicorn`, `celery`, and `python` +all resolve inside it), but what command that image *runs* is up to you: `docker-compose.yml`'s +`backend` service overrides it with `/entrypoint.sh`, which calls `manage.py runserver` — the +Django development server. For a production container or host process you supply your own start +command (see [Application server](#application-server) below) instead of that entrypoint script. + +Whatever you run it as, apply migrations and collect static files before serving traffic: + +```bash +cd backend +uv run python manage.py migrate +uv run python manage.py collectstatic --noinput +``` + +## Application server + +`backend/pyproject.toml` lists `gunicorn>=26.0.0` under its dependencies, commented "Production +WSGI server" — Gunicorn is the WSGI server this project ships as a dependency for serving the +Django app itself. `backend/crm/wsgi.py` exposes the standard `application` callable, so from +`backend/`: + +```bash +cd backend +uv run gunicorn crm.wsgi:application --bind 0.0.0.0:8000 +``` + +There is one documented exception to WSGI, in the codebase's own words. `backend/crm/asgi.py` +carries this docstring: + +> Production deploy must run an ASGI server pointing at this module: +> `uvicorn crm.asgi:application --host 0.0.0.0 --port 8000` + +The reason given is the in-app notifications SSE (server-sent events) stream: it's an async view +serving a long-lived connection, and running it under WSGI (Gunicorn) "will hold a worker +hostage" for the life of each connection. `crm/asgi.py` also optionally mounts the BottleCRM MCP +server at `/mcp` when the `mcp` extra is installed and `BCRM_MCP_ENABLED` isn't set to a falsy +value. `uvicorn[standard]>=0.51.0` is a listed dependency alongside Gunicorn for exactly this +path. In short: if you need the SSE notification stream (or the MCP mount) to work correctly +under concurrent load, serve via `uvicorn crm.asgi:application`, not Gunicorn/WSGI; if you don't, +Gunicorn against `crm.wsgi:application` is what this project bundles as its production WSGI +dependency. + +## Static files + +`whitenoise.middleware.WhiteNoiseMiddleware` is in `MIDDLEWARE` (immediately after +`SecurityMiddleware`), and `STATICFILES_STORAGE` is set to +`whitenoise.storage.CompressedManifestStaticFilesStorage`. This means Django itself serves static +assets — compressed and content-hashed — directly from the WSGI/ASGI process; you do not need a +separate static file server or a reverse-proxy `location /static/` block for correctness. Static +files are collected to `STATIC_ROOT` (`backend/staticfiles/`, i.e. `BASE_DIR/staticfiles`) under +`STATIC_URL = "/static/"` by `manage.py collectstatic`, which you must run yourself — Gunicorn +does not run it for you (the Docker Compose entrypoint does, but only because +`docker/backend/entrypoint.sh` calls it explicitly on every start). + +Media (user-uploaded files — invoice attachments, case attachments, and similar) is separate from +static files and is not covered by WhiteNoise. With `ENV_TYPE=dev` (the default), +`MEDIA_ROOT`/`MEDIA_URL` are a local `media/` directory served through Django itself; with +`ENV_TYPE=prod`, `backend/crm/server_settings.py` is imported instead and switches +`DEFAULT_FILE_STORAGE` to `storages.backends.s3boto3.S3Boto3Storage`, reading `AWS_BUCKET_NAME`, +`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SES_REGION_NAME`, `AWS_SES_REGION_ENDPOINT` and +`SENTRY_DSN` from the environment with `os.environ[...]` — a plain `KeyError` at import time, and +the process refusing to start, if any of them are unset. It also switches `EMAIL_BACKEND` to +`django_ses.SESBackend` and hardcodes `SESSION_COOKIE_DOMAIN = ".bottlecrm.io"`. That domain is +specific to the project's own SaaS hosting, not to your deployment — setting `ENV_TYPE=prod` +without also being ready to supply AWS S3, SES and Sentry credentials, and without meaning to +scope session cookies to `bottlecrm.io`, will not do what you want. See +[Required settings](#required-settings) below for the practical consequence. + +## Reverse proxy + +Nothing in `backend/crm/settings.py` terminates TLS, and `SECURE_PROXY_SSL_HEADER` is not set +anywhere in this codebase's settings modules. Concretely: if you terminate TLS at a reverse proxy +(nginx, Caddy, or similar) and forward plain HTTP to Gunicorn/Uvicorn, Django's +`request.is_secure()` will report `False` for every request unless you add +`SECURE_PROXY_SSL_HEADER` yourself (typically pointing at the `X-Forwarded-Proto` header your +proxy sets) — this project does not configure that for you. `SECURE_HSTS_SECONDS = 31536000` +(one year), `SECURE_HSTS_INCLUDE_SUBDOMAINS = True`, `SECURE_HSTS_PRELOAD = True` and +`SECURE_CONTENT_TYPE_NOSNIFF = True` are all set unconditionally in `backend/crm/settings.py`, but +Django's `SecurityMiddleware` only emits the `Strict-Transport-Security` header on requests it +considers secure — so without `SECURE_PROXY_SSL_HEADER` configured correctly for your proxy, that +HSTS configuration silently never takes effect. + +Point your proxy at whichever port your application server binds (`8000` in the examples above), +and route `/` there. There's no repo-provided proxy config (nginx site file, Caddyfile, or +similar) to point to — the specifics are yours to write. + +## Required settings + +`backend/crm/settings.py` reads these from the environment at import time (i.e. at process +startup, not per-request): + +| Variable | Behavior | +|---|---| +| `SECRET_KEY` | Defaults to the literal string `"django-insecure-dev-key-please-change-in-production"` if unset. The module itself checks this: if `SECRET_KEY` is empty or starts with `"django-insecure"` **and** `ENV_TYPE != "dev"`, it raises `ValueError("SECRET_KEY must be set to a secure value in non-dev environments")` at import time — a hard startup failure, not a warning. This guard only fires when `ENV_TYPE` is set to something other than `dev`; given the `ENV_TYPE=prod` consequences described above (S3/SES/Sentry env vars becoming mandatory, the hardcoded `.bottlecrm.io` cookie domain), don't rely on that check alone — set a real, unique `SECRET_KEY` regardless of `ENV_TYPE`. | +| `DEBUG` | Must be `False` in production. Read as `os.environ.get("DEBUG", "False").lower() == "true"` — defaults to `False` already, so the failure mode to avoid is *explicitly* setting `DEBUG=True` (as `.env.docker` does for local development) and carrying that into production. | +| `ALLOWED_HOSTS` | A comma-separated environment variable: `os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")`. Set it to the hostname(s) you'll actually be served on, e.g. `ALLOWED_HOSTS=crm.example.com`. | +| `CSRF_TRUSTED_ORIGINS` | Also comma-separated: `os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")`, empty by default. Django requires each entry to include a scheme (e.g. `https://crm.example.com`), not a bare hostname. | + +A few more the same module reads, worth setting deliberately rather than leaving at their +defaults: `CORS_ALLOWED_ORIGINS` (comma-separated, defaults to the two local dev frontend +origins) and `CORS_ALLOW_ALL` (`CORS_ORIGIN_ALLOW_ALL`, `.env.docker` sets this `True` for local +development — leave it `False` in production and rely on `CORS_ALLOWED_ORIGINS` instead), and +`DOMAIN_NAME`/`FRONTEND_URL`, which are interpolated into emails (magic links, notifications) and +so need to point at your real, public hostnames. diff --git a/docs/self-hosting/requirements.md b/docs/self-hosting/requirements.md new file mode 100644 index 00000000..8e9cbfac --- /dev/null +++ b/docs/self-hosting/requirements.md @@ -0,0 +1,65 @@ +# Requirements + +What has to be true about your environment before you follow [Docker](docker.md) or +[Production deployment](production-deploy.md). This page only covers versions, hardware shape and +services — see [Manual setup](../getting-started/manual-setup.md) for the commands that install +them. + +## Supported versions + +| Component | Version | Source | +|---|---|---| +| Python | 3.12 or later | `backend/pyproject.toml` sets `requires-python = ">=3.12"`; `backend/.python-version` pins `3.12` exactly. | +| Django | 6.0.7 or later | `backend/pyproject.toml` dependency `django>=6.0.7`. | +| PostgreSQL | 16 | `docker-compose.yml`'s `db` service uses image `postgres:16-alpine`. This is also the only version this project's own CI ever runs against — see the note below. | +| Redis | 7 | `docker-compose.yml`'s `redis` service uses image `redis:7-alpine`. | +| Node.js | 24 | `.github/workflows/tests.yml`'s `frontend-checks` job sets up `node-version: '24'`. | +| pnpm | 10 | Same job pins the pnpm action to `version: 10`. | + +Backend dependencies (`djangorestframework`, `djangorestframework-simplejwt`, `celery`, +`weasyprint`, `gunicorn`, and the rest) are locked in `backend/uv.lock`; `uv sync` from +`backend/` installs the exact versions CI and this documentation were checked against. + +**PostgreSQL is not optional.** BottleCRM's multi-tenancy is enforced by PostgreSQL Row-Level +Security (RLS), keyed on the `app.current_org` session variable — see +[PostgreSQL and RLS](postgresql-and-rls.md) for how that works and why it matters. RLS is a +PostgreSQL feature with no SQLite equivalent. The only place SQLite appears in this codebase is +`backend/crm/test_settings.py`, which swaps in an in-memory SQLite database for the test suite +specifically because "RLS ... is PostgreSQL-only and is skipped on SQLite" (its own docstring). +Running the application itself against SQLite is not supported by any settings module in this +repository. + +That gap is also why `.github/workflows/tests.yml`'s `backend-tests` job runs +`pytest -m "not postgres_only"` against SQLite and explicitly excludes every test marked +`postgres_only` — those tests need a real PostgreSQL connection to exercise RLS and don't run in +that job at all. Treat PostgreSQL 16, as pinned in `docker-compose.yml`, as the version this +project actually exercises; nothing in this repository documents support for, or testing against, +any other major version. + +## Hardware + +There is no benchmarked sizing guide in this repository, and this page won't invent one. What you +do need to plan capacity for is the process list a full self-hosted stack runs concurrently — +`docker-compose.yml` defines six long-running services: `db` (PostgreSQL), `redis`, `backend` +(the Django API), `celery-worker`, `celery-beat`, and `frontend`. All but `frontend` (only needed +if you're serving the SvelteKit dev server rather than a built static bundle) are required for the +API and background jobs to function; running them all on one host means sizing for a database, a +key-value store, an application server and a task worker at once, not just the Django process. +`backend/Dockerfile` also installs Cairo/Pango/gdk-pixbuf (WeasyPrint's PDF-rendering +dependencies) — PDF generation for invoices and estimates runs synchronously in the request or +task that triggers it, so factor that into whatever process handles it. + +## Required services + +- **PostgreSQL 16** — the only supported database backend (`django.db.backends.postgresql` is the + sole `ENGINE` any non-test settings module in this repository sets). Required for RLS-based + tenant isolation; see [PostgreSQL and RLS](postgresql-and-rls.md). +- **Redis** — `backend/crm/settings.py` reads `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND`, + both defaulting to `redis://localhost:6379/0`. Redis backs both the Celery message broker and + the result backend; without it, `celery -A crm worker` and `celery -A crm beat` have nothing to + connect to. + +Nothing else is required to run the API itself. Email delivery (console backend by default, SES +optionally), Google OAuth, and Sentry are all read from environment variables with working +defaults or an explicit off-state — see [Production deployment](production-deploy.md) for what +each of those needs when you turn it on. diff --git a/docs/self-hosting/security-hardening.md b/docs/self-hosting/security-hardening.md new file mode 100644 index 00000000..fb43eda9 --- /dev/null +++ b/docs/self-hosting/security-hardening.md @@ -0,0 +1,101 @@ +# Security hardening + +BottleCRM is multi-tenant: every deployment holds more than one organization's data behind the same +database and the same application process. This page collects the settings and surfaces that matter +most once you're running something other people depend on, most of which are covered in more depth +elsewhere in Self-hosting — this page is the checklist, with pointers to the detail. + +## Production checklist + +- **`DEBUG=False`.** `crm/settings.py` reads it as `os.environ.get("DEBUG", "False").lower() == + "true"`, so it's already `False` by default — the failure mode to avoid is `.env.docker` (which + ships `DEBUG=True` for local development) carrying into a production environment unchanged. +- **A real `SECRET_KEY`**, not the shipped `django-insecure-...` placeholder — see + [Secrets](#secrets) below. +- **`ALLOWED_HOSTS`** set to your real hostname(s), and **`CSRF_TRUSTED_ORIGINS`** set with a scheme + (`https://crm.example.com`, not a bare hostname) — both detailed in + [Production deployment](production-deploy.md#required-settings). +- **`CORS_ALLOW_ALL=False`** (the default) with **`CORS_ALLOWED_ORIGINS`** scoped to your real + frontend origin(s); `.env.docker` sets `CORS_ALLOW_ALL=True` for local development, which should + not carry into production either. +- **`SECURE_PROXY_SSL_HEADER`** added yourself if you terminate TLS at a reverse proxy — nothing in + `crm/settings.py` sets it, so without it Django never considers a proxied request secure and the + HSTS headers this project does set unconditionally (`SECURE_HSTS_SECONDS`, + `SECURE_HSTS_INCLUDE_SUBDOMAINS`, `SECURE_HSTS_PRELOAD`) silently never get sent — see + [Production deployment](production-deploy.md#reverse-proxy). +- **A non-superuser database role** — see [Database role](#database-role) below. +- **Don't set `ENV_TYPE=prod` casually.** It's not just a flag: it makes `AWS_BUCKET_NAME`, + `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SES_REGION_NAME`, `AWS_SES_REGION_ENDPOINT` and + `SENTRY_DSN` mandatory (a `KeyError` at startup if any is missing), switches email to Amazon SES, + and hardcodes `SESSION_COOKIE_DOMAIN = ".bottlecrm.io"` — a domain specific to this project's own + SaaS hosting. See [Production deployment](production-deploy.md#static-files) before setting it. + +## Database role + +The single highest-leverage setting in this list: `DBUSER` must not be a PostgreSQL superuser, or +every Row-Level Security policy in the schema is bypassed silently, with no error and no log line. +The default (`DBUSER` unset, falling back to `postgres`) is a superuser on most installations. Create +a dedicated role and confirm it isn't one: + +```bash +cd backend +uv run python manage.py manage_rls --verify-user +``` + +Full detail — how to create the role, how RLS policies are applied, and how to prove isolation holds +for real data rather than trusting that it does — is in +[PostgreSQL and RLS](postgresql-and-rls.md). + +## Secrets + +`SECRET_KEY` does more than Django's usual signing duties here: `SIMPLE_JWT["SIGNING_KEY"]` in +`crm/settings.py` is set to `SECRET_KEY` directly, so it is also the key every access and refresh +token is signed and verified with. There is no separate JWT signing-key setting anywhere in this +codebase — rotating `SECRET_KEY` invalidates every outstanding access and refresh token immediately, +which is worth knowing before you rotate it as a routine maintenance step rather than in response to +an actual compromise. + +`crm/settings.py` itself guards against the worst case of forgetting to set it: if `SECRET_KEY` is +empty or still starts with `"django-insecure"` **and** `ENV_TYPE` is anything other than `dev`, the +module raises `ValueError("SECRET_KEY must be set to a secure value in non-dev environments")` at +import time — a hard startup failure rather than a silent insecure default. That guard only fires +when `ENV_TYPE` is explicitly non-`dev`, so set a real, unique `SECRET_KEY` regardless of whether you +ever set `ENV_TYPE=prod`. + +## Token surfaces + +Three different credential types exist, with different blast radii if one leaks: + +- **Personal access tokens** (`bcrm_pat_...`, managed at `profile/tokens/`) are scoped to the one + profile that created them. `resolve_valid_pat()` in `backend/common/pat_auth.py` checks + `pat.profile.is_active` (and `pat.org.is_active`) on every use, so deactivating a profile cuts off + its personal access tokens immediately — there's no separate token-revocation step required when + offboarding someone. +- **Admin oversight of tokens** is separate from the self-service surface above: `org/tokens/` + (`OrgAccessTokenListView`/`OrgAccessTokenDetailView`) is admin-only and org-wide — an admin can see + and revoke any token in their org, including one belonging to a colleague who has since been + deactivated. It's deliberately a distinct endpoint from `profile/tokens/` rather than a widened + version of it, so the self-service guard never has to be loosened to support oversight. +- **The org API key** (`GET`/`POST /api/org/api-key/`, `OrgApiKeyView`, admin-only) is the highest- + privilege of the three: `common/middleware/get_company.py`'s `_process_api_key_auth` resolves a + request bearing this key to the org's first active `ADMIN` profile, so a leaked org API key is + equivalent to a leaked admin session for that org, not a leaked user session. It's kept out of + every nested API representation and served only by this endpoint. If it's ever exposed — committed + to a repo, logged, shared over an insecure channel — rotate it with `POST /api/org/api-key/`; the + response is explicit that "the previous key is no longer valid" immediately on rotation. + +## What to monitor + +`SecurityAuditLog` (`backend/common/audit_log.py`, table `security_audit_log`) is an RLS-protected, +per-org table of security-relevant events, with a matching `security.audit` Python logger that also +writes to `security_audit.log` (configured in `crm/settings.py`'s `LOGGING` dict). It's worth +watching, with one caveat: of the thirteen event types the model defines +(`LOGIN_SUCCESS`, `LOGIN_FAILURE`, `LOGOUT`, `ORG_SWITCH`, `TOKEN_REFRESH`, `TOKEN_REVOKED`, +`PERMISSION_DENIED`, `CROSS_ORG_ATTEMPT`, `API_KEY_USED`, `API_KEY_INVALID`, `MEMBERSHIP_REVOKED`, +`SUSPICIOUS_ACTIVITY`, `SAMPLE_DATA_CLEARED`), only `LOGIN_SUCCESS`, `ORG_SWITCH`, `TOKEN_REFRESH`, +`TOKEN_REVOKED`, `PERMISSION_DENIED` (from one call site) and `SAMPLE_DATA_CLEARED` are actually +logged anywhere in the application code as of this writing. `LOGIN_FAILURE`, `LOGOUT`, +`CROSS_ORG_ATTEMPT`, `API_KEY_USED`, `API_KEY_INVALID`, `MEMBERSHIP_REVOKED` and +`SUSPICIOUS_ACTIVITY` are defined on the model and have corresponding methods on the `AuditLogger` +helper, but nothing in the codebase currently calls them — don't rely on this table to surface a +brute-forced login or a bad API key attempt today; it doesn't yet. diff --git a/docs/self-hosting/troubleshooting.md b/docs/self-hosting/troubleshooting.md new file mode 100644 index 00000000..dedf1830 --- /dev/null +++ b/docs/self-hosting/troubleshooting.md @@ -0,0 +1,109 @@ +# Troubleshooting + +Five symptoms that come up often enough in a multi-tenant, RLS-backed deployment to be worth their +own page, each with the specific cause and a command that confirms it rather than a guess. + +## Every list is empty + +Two distinct, verifiable causes, depending on where you're looking: + +**Through the API**, `HasOrgContext` (`backend/common/permissions.py`) is the permission class most +list/detail views use alongside `IsAuthenticated`. It denies with a `403` and the message +`"Organization context is required. Please login again."` whenever `request.profile` or +`request.org` is unset — which happens when the JWT you're sending has no `org_id` claim (a token +minted without picking an org yet — see [First sign-in](../getting-started/first-sign-in.md#choosing-an-organization)) +or belongs to a profile that's no longer active in that org. That response body is the confirmation: +if you're getting `403` with that message rather than an empty `200`, this is it, and the fix is to +obtain a token with the right org bound (via `/api/auth/switch-org/`, or `devlogin --org` in +development). + +**Through Django admin** (`/admin/`), the failure looks different and is easy to miss: `/admin/` is +listed in `RequireOrgContext.EXEMPT_PATHS` (`backend/common/middleware/rls_context.py`), which +exempts it from *requiring* org context — but exemption from requiring a context is not the same as +*setting* one, and nothing else sets `app.current_org` for an admin-authenticated request either. On +a correctly configured, non-superuser database role, every RLS-protected model registered in Django +admin (`Lead`, `Task`, `Board`, `Invoice`, and others — see each app's `admin.py`) will show **zero +rows in the admin list view even though the data exists**, because the RLS policy's +`NULLIF(current_setting('app.current_org', true), '')` evaluates to `NULL` with no context set, and +`org_id::text = NULL` is never true. Confirm this by comparing counts: the same model returns real +data through `GET /api/leads/` (which does have org context) but nothing through the `Lead` admin +changelist at `/admin/leads/lead/` (`/admin/leads/` alone is the app index, listing model names, not +rows — it won't show this). This is expected behavior, not a bug to fix — Django admin was never +wired to set RLS context, and doing so would need to resolve which org an admin-site request is +"for," which the admin UI itself has no concept of. + +## Tenant data is visible across organizations + +This is the fail-safe design inverted, and it has one cause in this codebase: the database role +Django connects as is a PostgreSQL superuser, and superusers bypass Row-Level Security entirely, +silently — no error, no log line, every policy still exists and looks correctly configured. Confirm +it directly: + +```bash +cd backend +uv run python manage.py manage_rls --verify-user +``` + +This prints `Is superuser: True` and explicit remediation SQL if the role is a superuser, or a +success line if it isn't. The default `DBUSER` (unset, falling back to `postgres`) is a superuser on +most PostgreSQL installations — see [PostgreSQL and RLS](postgresql-and-rls.md#creating-the-application-role) +for how to create and switch to a dedicated non-superuser role, and don't trust a green local/dev +environment as proof this is configured correctly: a superuser dev database makes every endpoint +*look* correctly isolated purely because the application's own `.filter(org=...)` calls happen to be +right, while RLS itself does nothing underneath them. + +## Celery tasks see no data + +A Celery worker never runs Django's request middleware, so nothing sets `app.current_org` for it the +way `RequireOrgContext` does for an HTTP request. Every task in `backend/common/tasks.py` that +queries an org-scoped table is expected to call `set_rls_context(org_id)`, also defined in that +module, as its first line before touching the ORM. A task that queries an RLS-protected model +without calling it first runs with an empty RLS context on a fresh worker connection, which — per +the same fail-safe design as above — means the query returns **zero rows**, not the wrong org's +rows, but it does mean the task silently does nothing useful. Confirm by reading the task itself for +a `set_rls_context(...)` call before its first org-scoped query; there's no runtime flag or log line +that surfaces this automatically. See [Email and Celery](email-and-celery.md#rls-in-background-tasks) +for the pattern and the two tasks that intentionally skip it. + +## Sign-in redirects in a loop + +The SvelteKit frontend stores JWT tokens in httpOnly cookies whose `secure` flag is gated on +`NODE_ENV`/`env.NODE_ENV === 'production'` — this pattern repeats across +`frontend/src/hooks.server.js` and every `+page.server.js` that sets an auth-related cookie (login, +magic-link verify, org selection). If you set `NODE_ENV=production` on the Node process serving the +frontend while the site is actually reached over plain HTTP (no TLS terminated in front of it, or a +proxy that isn't forwarding correctly), every cookie gets written with the `Secure` attribute, and +the browser silently refuses to store or send a `Secure` cookie over a non-HTTPS connection. The +result: sign-in appears to succeed (the OAuth/magic-link exchange completes and the server tries to +set the cookies), but the next request arrives with no `jwt_access` cookie, `hooks.server.js` treats +it as unauthenticated, and redirects back to `/login` — which the user completes again, with the +same outcome. Confirm it by inspecting the `Set-Cookie` response header in the browser's network +tab: a `Secure` attribute on a response served over `http://` is the signature of this exact failure. +The fix is to serve the frontend over HTTPS if `NODE_ENV=production` is set, or leave `NODE_ENV` +unset/`development` until TLS is actually in front of it. + +## Migrations fail + +`manage.py migrate` is also what applies and re-applies Row-Level Security policies — see +[PostgreSQL and RLS](postgresql-and-rls.md#enabling-policies). Two things about how those specific +migrations are written are worth knowing if `migrate` fails partway through: they run with +`atomic = False` (every migration that calls `get_enable_policy_sql` carries the comment "RLS policy +creation can't run inside an atomic block"), and the SQL those migrations run always +`DROP POLICY IF EXISTS` before `CREATE POLICY`, so re-running `manage.py migrate` after an +interrupted run is safe rather than something that fails on a duplicate policy. + +`ALTER TABLE ... ENABLE ROW LEVEL SECURITY`, which these migrations run, requires the connecting +role to either be the table's owner or a superuser. As long as the same non-superuser role runs +every migration for a given database from the very first one — the setup this project documents in +[PostgreSQL and RLS](postgresql-and-rls.md#creating-the-application-role) — that role owns every +table it creates and this is never an issue. It becomes one if you provision the schema differently: +for example, running the first `migrate` as the `postgres` superuser and only switching `DBUSER` to +a dedicated non-superuser role afterward leaves that role without ownership of the already-created +tables, and a later migration's `ALTER TABLE` fails with a permission error naming the table. If +`migrate` fails this way, check which role owns the affected table (`\dt+
` in `psql`) against +the role in `DBUSER`. + +Separately, if you've forked this project and modified a model: this project's own CI runs +`manage.py makemigrations --check --dry-run` before its test suite specifically to catch a model +change that never got its migration generated. Run that same command locally after any model +change. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..8b607dba --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,112 @@ +site_name: BottleCRM +site_description: Self-hosted, multi-tenant CRM — setup, self-hosting, API and development documentation. +site_url: https://bottlecrm.readthedocs.io/ +repo_url: https://github.com/django-crm/Django-CRM +repo_name: django-crm/Django-CRM +edit_uri: edit/master/docs/ + +docs_dir: docs + +# docs/ also holds internal specs, plans and design references. MkDocs copies +# every file under docs_dir into the built site regardless of nav, so these +# must be excluded explicitly or they are published. +exclude_docs: | + superpowers/ + design/ + +theme: + name: material + features: + - navigation.sections + - navigation.top + - navigation.instant + - content.code.copy + - content.action.edit + - search.suggest + - toc.follow + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/weather-sunny + name: Switch to light mode + +markdown_extensions: + - admonition + - attr_list + - def_list + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + +nav: + - Home: index.md + - Getting started: + - Docker quick start: getting-started/docker-quick-start.md + - Manual setup: getting-started/manual-setup.md + - First sign-in: getting-started/first-sign-in.md + - Demo data and packs: getting-started/demo-data.md + - Self-hosting: + - Requirements: self-hosting/requirements.md + - Docker: self-hosting/docker.md + - Production deployment: self-hosting/production-deploy.md + - PostgreSQL and RLS: self-hosting/postgresql-and-rls.md + - Environment variables: self-hosting/environment-variables.md + - Email and Celery: self-hosting/email-and-celery.md + - Google OAuth: self-hosting/google-oauth.md + - Backups and upgrades: self-hosting/backups-and-upgrades.md + - Security hardening: self-hosting/security-hardening.md + - Troubleshooting: self-hosting/troubleshooting.md + - Architecture: + - Overview: architecture/overview.md + - Multi-tenancy and RLS: architecture/multi-tenancy-and-rls.md + - Authentication: architecture/authentication.md + - Permissions and roles: architecture/permissions-and-roles.md + - Data model: architecture/data-model.md + - Background jobs: architecture/background-jobs.md + - API reference: + - Conventions: api/conventions.md + - Authentication: api/authentication.md + - Errors: api/errors.md + - Tokens and API keys: api/tokens-and-api-keys.md + - Leads: api/leads.md + - Accounts: api/accounts.md + - Contacts: api/contacts.md + - Opportunities: api/opportunities.md + - Cases: api/cases.md + - Tasks: api/tasks.md + - Invoices and estimates: api/invoices.md + - Endpoint index: api/endpoint-index.md + - Integrations: + - MCP server: integrations/mcp-server.md + - Inbound email: integrations/inbound-email.md + - CSV import: integrations/csv-import.md + - Python sample: integrations/python-sample.md + - JavaScript sample: integrations/javascript-sample.md + - Mobile app: + - Build and configure: mobile/build-and-configure.md + - Connect to self-hosted: mobile/connect-to-self-hosted.md + - Release builds: mobile/release-builds.md + - Contributing: + - Development setup: contributing/development-setup.md + - Repository layout: contributing/repo-layout.md + - Testing: contributing/testing.md + - Code style: contributing/code-style.md + - Adding an org-scoped model: contributing/adding-an-org-scoped-model.md + - Security rules: contributing/security-rules.md + - Pull requests: contributing/pull-requests.md + - Reference: + - Environment variables: reference/environment-variables.md + - Management commands: reference/management-commands.md + - Glossary: reference/glossary.md