diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cffbe43 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# TCRM Toolkit Configuration +# Copy this file to .env and fill in your values + +# Application Settings +APP_NAME=tcrm-toolkit +APP_VERSION=0.1.0 +DEBUG=false +LOG_LEVEL=INFO + +# Salesforce API Settings +SF_API_VERSION=v60.0 +SF_DEFAULT_DOMAIN=login.salesforce.com + +# Encryption Settings +# Generate a secure key: python -c "import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())" +ENCRYPTION_KEY=your-base64-encoded-32-byte-key-here + +# JWT Settings (for Connected App JWT Bearer flow) +JWT_SECRET_KEY=your-jwt-secret-key-min-32-chars +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Connected App Credentials (for JWT Bearer flow) +# SF_CONNECTED_APP_CLIENT_ID=your-consumer-key +# SF_CONNECTED_APP_CLIENT_SECRET=your-consumer-secret +# SF_CONNECTED_APP_USERNAME=your-salesforce-username + +# Web OAuth Settings (for PKCE flow) +# SF_WEB_OAUTH_CLIENT_ID=your-consumer-key +# SF_WEB_OAUTH_CLIENT_SECRET=your-consumer-secret +# SF_WEB_OAUTH_REDIRECT_URI=http://localhost:8080/callback + +# Device Flow Settings (for headless/terminal login) +# SF_DEVICE_FLOW_CLIENT_ID=your-consumer-key \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..44ef39f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main, refactor/**, feature/**] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install dependencies + run: uv sync --extra interactive --extra dev + + - name: Run linting + run: uv run ruff check . + continue-on-error: true + + - name: Run type checking + run: uv run mypy tcrm_toolkit + continue-on-error: true + + - name: Run tests + run: uv run pytest -v --tb=short + + - name: Verify cross-platform + run: uv run python scripts/verify-cross-platform.py + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t crma-toolkit:test . + + - name: Test Docker image + run: docker run --rm crma-toolkit:test --help diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91b3598 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +env/ +.env + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Test coverage +.coverage +htmlcov/ +.pytest_cache/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json + +# Ruff +.ruff_cache/ + +# Local development +.env.local +.env.*.local + +# Keyring (if using file backend) +*.keyring + +# PDF outputs +*.pdf \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..57c59f0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ +# AGENTS.md — TCRM Toolkit + +Guidance for agents working in this repository. Read before editing. + +## Project + +Modern async Python CLI for Salesforce Tableau CRM (TCRM). Rebuilds the legacy +`FOSS_Toolkit.py` (now preserved under `_legacy/`) with a `typer` CLI + +`textual` interactive TUI. Current work lives on `feature/interactive-tui-complete` +(the old `main` only holds the dead legacy toolkit). + +--- + +## Mandatory GitHub & Git Workflow + +- **Repository**: `pg-dev-git/foss_analytics_toolkit` +- **Never work directly on `main` (or `master`).** +- **Branch Naming**: Always create a feature branch from `main`: + - `feature/` for new features (e.g., `feature/order-form`, `feature/product-image-upload`) + - `fix/` for bug fixes (e.g., `fix/payment-code-generation`) + - `chore/` for maintenance/infrastructure (e.g., `chore/docker-compose`) + - `docs/` for documentation updates (e.g., `docs/prd-and-phases`) +- **Standard GitHub Flow**: + 1. Create and switch to feature branch: `git checkout -b feature/your-feature-name` + 2. Make small, atomic commits with conventional commit messages. + 3. Push branch to remote: `git push -u origin feature/your-feature-name` + 4. Open a Pull Request against `main` using `create_pull_request` (do not merge to `main` without review / completion of phase). + +### Conventional Commits Guidelines +Use the format `(): `: +- `feat(cart): add quantity increment/decrement steppers` +- `fix(checkout): resolve missing phone number validation error` +- `style(ui): update mobile touch target padding in product grid` +- `refactor(pb): extract pocketbase query helper functions` +- `docs(readme): add cloudflare tunnel setup instructions` + +--- + +## Environment & Commands + +- Use **uv**, not pip. Run everything via `uv run ...`. +- Install deps: `uv sync --extra interactive --extra dev` +- Tests: `uv run pytest -v --tb=short` (expect 42 passing) +- Lint: `uv run ruff check .` · Typecheck: `uv run mypy tcrm_toolkit` +- Cross-platform check: `uv run python scripts/verify-cross-platform.py` +- CLI entrypoint: `tcrm` (`tcrm --help`, `tcrm doctor`) +- Tests need real encryption keys — `conftest.py` provides them; copy + `.env.example` → `.env` only if you need live settings. + +## Architecture (layered — respect the dependency direction) + +`cli/` → `interactive/` → `core/services/` → `core/` (client, models, config, crypto, auth) + +- **cli/** — `typer` command groups (`commands/{auth,dashboards,dataflows,datasets,jobs}.py`). + Wire user actions directly to `core/services/`. Register with + `app.add_typer(sub_app, name="...")` in `cli/main.py`. +- **interactive/** — `textual` TUI: `screens/` (Login, Main, OrgPicker, SafetyModal, Help), + `widgets/` (DataBrowser, DetailPanel, StatusBar, CommandPalette, ...), + `operations/` (extract/upload/backup/control), `tasks.py` (`TaskRunner` + `ProcessPoolExecutor`). +- **core/services/** — the real business logic: `dataset_service`, `dashboard_service`, + `dataflow_service`, `auth_service`. These are async and already exercised by tests. + `session.py::SessionManager` owns auth + `client_context()`; every network call must go + through it, never construct a bare client. +- **core/** — `client.py` (httpx + tenacity retries), `models/` (pydantic), `config.py` + (pydantic-settings, `TCRM_*` env vars), `crypto.py` (dynamic-salt encryption), + `auth/{sf_cli_auth,token_store}.py`. + +## Conventions + +- Python 3.11+, `pathlib.Path` everywhere (no path separators hardcoded). +- Async throughout: `async/await`, `asyncio.gather`/`Semaphore` for I/O, + `ProcessPoolExecutor` only for CPU-bound work (pandas). +- `structlog` for logging. `datetime.now(timezone.utc)` — **not** `datetime.utcnow()` + (deprecated, flagged in CI warnings). +- **Error Logging**: Centralized structured JSON logging via `core/logger.py` writes concurrently to `stderr` and persistent log file `~/.tcrm/tcrm.log`. TUI unhandled errors and background worker failures are intercepted and logged via `TCRMApp.on_error`. +- Keep it simple: no special-case glue, no dead stubs. Prefer eliminating a branch over + guarding it. + +## Testing + +- `tests/unit/` (crypto, client retry, auth schemas, interactive) + + `tests/integration/test_api_endpoints.py` (uses `AsyncMock` on `SalesforceClient._client`). +- Add tests for new/changed behaviour. Assert on real outputs/state, not mocked calls. +- Integration tests mock the HTTP layer via the `mock_client` fixture — exercise real + service code, not the client. +- **Live Session Seeding & E2E Testing**: + - To test against a live Salesforce org without interactive browser login, use `scripts/seed_session.py`: + ```bash + TCRM_ACCESS_TOKEN="..." TCRM_INSTANCE_URL="..." TCRM_USERNAME="..." uv run python scripts/seed_session.py + ``` + - **Token Expiry**: If a live session token expires or encounters authentication errors during testing, the agent must immediately notify the user to request a fresh token. + +## Gotchas + +- **Phases are NOT all done.** Phases 0–2 are complete. Phase 3 (operations/background + tasks) and Phase 4 (polish/DX) are partial — see the phase-completion review. + `TASK_LOG.md` checkboxes are stale; trust `docs/plans/phases/README.md`. +- CLI write commands are currently **stubs** (`"Authentication integration pending"`, + `datasets.py` even raises `NotImplementedError`). The service layer is done; the + CLI↔keyring↔service auth bridge is not. +- `widgets/progress_panel.py` and `widgets/task_history.py` are empty `Static` subclasses. +- CI on `main` does not run (workflow targets `main`/`refactor/**`/`feature/**`; `main` + holds only the legacy toolkit). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7f97852 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# ============================================================================= +# CRMA Toolkit - Interactive TUI Docker Image +# Multi-stage build for minimal production image +# ============================================================================= + +# ---- Build Stage ---- +FROM python:3.12-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app + +COPY pyproject.toml uv.lock* ./ +COPY . . + +RUN uv pip install --system -e .[interactive,dev] + +# ---- Runtime Stage ---- +FROM python:3.12-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + gnupg \ + procps \ + npm \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g @salesforce/cli + +RUN useradd -m -s /bin/bash tcrm && \ + mkdir -p /home/tcrm/.config /home/tcrm/.local/share && \ + chown -R tcrm:tcrm /home/tcrm + +COPY --from=builder /app /app +RUN chown -R tcrm:tcrm /app +WORKDIR /app +RUN pip install -e . + +ENV HOME="/home/tcrm" +ENV USER="tcrm" + +USER tcrm +WORKDIR /home/tcrm + +ENTRYPOINT ["tcrm"] +CMD ["--help"] diff --git a/FOSS_Toolkit.py b/FOSS_Toolkit.py index 3ba2c4c..7f7afa7 100644 --- a/FOSS_Toolkit.py +++ b/FOSS_Toolkit.py @@ -1,17 +1,20 @@ -import json, requests, os, configparser, datetime, time, multiprocessing as mp -from misc_tasks.terminal_colors import * -from misc_tasks.sfdc_login import * -from dataset_tasks.get_datasets import * -from dataset_tasks.csv_new_dataset import * -from dataflow_tasks.get_dataflows import * -from data_manager_tasks.get_dataflowjobs import * -from misc_tasks.initial_checks import * +import multiprocessing as mp +import os +import time + from dashboards_tasks.get_dashboards import * from dashboards_tasks.mass_dashboard_backup import * -from misc_tasks.get_ea_limits import * -from misc_tasks.line import * +from data_manager_tasks.get_dataflowjobs import * +from dataflow_tasks.get_dataflows import * from dataflow_tasks.mass_dataflows_backup import * +from dataset_tasks.csv_new_dataset import * +from dataset_tasks.get_datasets import * from dataset_tasks.mass_user_xmd_backup import * +from misc_tasks.get_ea_limits import * +from misc_tasks.initial_checks import * +from misc_tasks.line import * +from misc_tasks.sfdc_login import * +from misc_tasks.terminal_colors import * if __name__ == "__main__": diff --git a/PROMPT-phase-3-operations.md b/PROMPT-phase-3-operations.md new file mode 100644 index 0000000..249fd0e --- /dev/null +++ b/PROMPT-phase-3-operations.md @@ -0,0 +1,124 @@ +# Phase 3 Prompt — Operations & Background Tasks + +Copy the text below into a fresh agent session. It is self-contained. + +--- + +You are implementing the TCRM Toolkit (a modern async Python CLI + `textual` TUI for +Salesforce Tableau CRM). Your job is to complete **Phase 3: Operations & Background +Tasks** and pick up the work exactly where the previous review left off. + +## 0. Before writing any code + +1. **Read `AGENTS.md` in the repo root and follow it.** It contains the environment, + architecture, conventions, testing, and gotchas you must respect. +2. Read the authoritative spec: `docs/plans/phases/phase-3-operations-background-tasks.md`. + The code blocks in that document are the reference implementation you should complete. +3. Read the **phase-completion review** (the previous assistant's summary): the current + branch is `feature/interactive-tui-complete`. Phases 0–2 are complete; **Phase 3 is + partial** — that is your starting point. Do not touch Phase 0–2 code unless a change is + required to complete Phase 3. +4. Set up the environment first: + ```bash + uv sync --extra interactive --extra dev + uv run pytest -v --tb=short # baseline: 42 passing + ``` + If tests don't pass at baseline, stop and report — do not proceed. + +## 1. Context: what is already done (do NOT re-implement) + +- `tasks.py::TaskRunner` exists and is tested (async execution, `ProcessPoolExecutor`, + history, cancellation). `merge_csv_chunks`, `process_csv_chunk`, `split_csv_for_parallel` + helpers already exist. +- All four **service layers** are complete and tested: + `core/services/{dataset,dashboard,dataflow}_service.py` and `auth_service.py`. + Real signatures you must call: + - `DatasetService.extract_dataset`, `extract_dataset_streaming`, `upload_csv`, + `upload_csv_streaming`, `delete_dataset`, `get_dataset`, `get_dataset_xmd`, + `get_row_count`, `get_dataset_dependencies`, plus private + `_calculate_chunk_size`, `_build_saql_query`, `_extract_fields_from_xmd`. + - `DataflowService.start_dataflow`, `stop_dataflow`, `wait_for_dataflow_job`, + `backup_dataflow`, `list_dataflow_jobs`, `get_dataflow_job_status`. + - `DashboardService.backup_dashboard`, `restore_dashboard`, `delete_dashboard`. +- `session.py::SessionManager` owns auth and exposes `client_context()`, `get_client()`, + `ensure_valid_token()`. **Every** network call must go through the session — never build + a bare `SalesforceClient`. + +## 2. What is MISSING (your work) + +Implement these, matching the reference implementations in the phase doc: + +1. **`tcrm_toolkit/interactive/operations/dataset_extract.py`** — `ParallelDatasetExtractor` + (parallel SAQL queries + `ProcessPoolExecutor` merge). Use the existing `tasks.py` + helpers and `ExtractionProgress` from `core.models`. +2. **`tcrm_toolkit/interactive/operations/dataset_upload.py`** — `ParallelDatasetUploader` + (chunk + stream uploads). +3. **`tcrm_toolkit/interactive/operations/dashboard_backup.py`** — background dashboard + backup/restore tasks. +4. **`tcrm_toolkit/interactive/operations/dataflow_control.py`** — start/stop/jobs + control with job-status polling. +5. **`widgets/progress_panel.py`** and **`widgets/task_history.py`** — replace the empty + `Static` subclasses with real widgets that render progress and task history. Wire them + into `main_screen.py` where the phase doc specifies. + +## 3. Critical blocker: the CLI↔auth bridge + +All CLI `datasets`, `dashboards`, `dataflows`, `jobs` commands are currently stubs that +print `"Authentication integration pending"`; `cli/commands/datasets.py` even raises +`NotImplementedError("Need to implement token retrieval from keyring")`. The service layer +is done but **unauthenticated**. Fix this so the CLI actually works end-to-end: + +- Wire each command through `SessionManager` / `AuthService` / `keyring` + `token_store` + to the real service layer. `AuthService` exposes `store_tokens`, `retrieve_tokens`, + `delete_tokens`, `is_token_expired`, `ensure_valid_token`. +- Do **not** leave placeholders. Remove every `"Authentication integration pending"` and + the `NotImplementedError`. The commands should behave like the read commands already do + once authenticated. + +## 4. Constraints & quality bar (from AGENTS.md) + +- Python 3.11+, `pathlib.Path` everywhere, async throughout + (`asyncio.gather`/`Semaphore` for I/O, `ProcessPoolExecutor` only for CPU-bound pandas). +- Use `structlog` for logging; use `datetime.now(timezone.utc)` — **never** `datetime.utcnow()`. +- Respect the layered architecture: `cli/ → interactive/ → core/services/ → core/`. +- Keep it simple. No special-case glue, no dead code, no stubs. Prefer eliminating a + branch over guarding it. +- Cross-platform: no hardcoded path separators; handle Windows/Linux/macOS. + +## 5. Tests + +- Add tests for everything new. Prefer real code paths with the `mock_client` fixture + (mocks the HTTP layer via `SalesforceClient._client` = `AsyncMock`); exercise real + service/operation logic and assert on outputs/state — not mocked calls. +- Put interactive-operation tests under `tests/unit/` and any API-path tests under + `tests/integration/`. +- Add fixtures for `ENCRYPTION_KEY`/`JWT_SECRET_KEY` if needed (see `conftest.py`). + +## 6. Verify before you finish + +```bash +uv run pytest -v --tb=short # all passing (was 42 baseline; now includes new tests) +uv run ruff check . +uv run mypy tcrm_toolkit +uv run python scripts/verify-cross-platform.py +uv run tcrm --help # CLI still loads; commands registered +``` + +If `ruff`/`mypy` report issues, fix them (lint/typecheck are non-blocking in CI, but +ship clean). Do not commit if the test suite is red. + +## 7. Report back + +Provide a concise summary: +- Which files were created/changed and what each does. +- How you wired the CLI auth bridge (which session/auth primitives you used). +- The final test/lint/typecheck/cross-platform results. +- Any deviations from the phase doc and why. +- Suggested next steps for **Phase 4** (themes/config persistence/command-palette wiring, + including a broken F1 `action_help` binding and a stub command palette) if relevant. + +## Non-goals + +- Do not rework Phases 0–2. +- Do not implement Phase 5 (parked value-add features). +- Do not change the public CLI surface (command names/flags) unless a bug requires it. diff --git a/PROMPT-phase-4-polish-dx.md b/PROMPT-phase-4-polish-dx.md new file mode 100644 index 0000000..7911df9 --- /dev/null +++ b/PROMPT-phase-4-polish-dx.md @@ -0,0 +1,148 @@ +# Phase 4 Prompt — Polish & Developer Experience + +Copy the text below into a fresh agent session. It is self-contained. + +--- + +You are implementing the TCRM Toolkit (a modern async Python CLI + `textual` TUI for +Salesforce Tableau CRM). Your job is to complete **Phase 4: Polish & Developer Experience** +and pick up the work exactly where the previous phase left off. + +## 0. Before writing any code + +1. **Read `AGENTS.md` in the repo root and follow it.** It contains the environment, + architecture, conventions, testing, and gotchas you must respect. +2. Read the authoritative spec: `docs/plans/phases/phase-4-polish-dx.md` (especially the + **Acceptance Criteria** table near the end and the "Implementation Order"). +3. Read the previous phase-completion review: the current branch is + `feature/interactive-tui-complete`, Phase 3 is complete. **Phase 4 is ~60% done** — + many files already exist but the wiring is incomplete. This is your starting point. +4. Set up the environment first: + ```bash + uv sync --extra interactive --extra dev + uv run pytest -v --tb=short # baseline: 43 passing + ``` + If tests don't pass at baseline, stop and report — do not proceed. + +## 1. Context: what is already done (do NOT re-implement) + +- **Themes** — `interactive/styles/{default,dark,light}.css` exist and are wired. +- **Config model** — `interactive/config.py::TUIConfig` (Pydantic-settings, `TCRM_TUI_*` + env vars: `theme`, `sidebar_width`, `detail_panel_width`). +- **Config persistence** — `interactive/config_manager.py::ConfigManager` (JSON in + `~/.tcrm/`, `save`/`load`/`get`/`set`). +- **Window state** — `interactive/window_manager.py::WindowManager` (JSON in `~/.tcrm/` + for window size/preferences). +- **Notifications** — `interactive/notifications.py::NotificationManager` exists. +- **Help screen** — `interactive/screens/help_screen.py::HelpScreen` exists (tabbed, + categorized shortcuts) and is exported from `screens/__init__.py`. +- **Task History / Progress Panel** — `interactive/widgets/{task_history,progress_panel}.py` + are now real widgets (completed in Phase 3). + +## 2. What is MISSING / INCOMPLETE (your work) + +These gaps were confirmed against the live code and must be closed: + +1. **F1 help binding is broken (bug).** `interactive/app.py` registers + `Binding("f1", "help", ...)` but **no `action_help` method exists anywhere** — pressing + F1 raises a `BindingError`. Add `action_help` (on `TCRMApp`, or dispatch to + `MainScreen`) that launches the existing `HelpScreen` modal. +2. **Command palette is a stub.** + `interactive/screens/main_screen.py:167` calls + `self.notify("Command palette coming in Phase 4", ...)`. Implement a real fuzzy-search + command palette: `Ctrl+P` should show searchable actions (e.g. "Extract dataset", + "View Task History", "Switch org", "Help") and dispatch them. `interactive/widgets/ + command_palette.py` already exists — wire it in (or improve it). This is a key + acceptance criterion. +3. **Column-width persistence is not implemented.** `window_manager.py` documents + "column widths" but persists nothing related to columns, and + `interactive/widgets/data_table.py` never saves/restores column widths. Implement + save-on-resize / restore-on-init for `DataBrowser` columns via `WindowManager` (or + `ConfigManager`). This is a key acceptance criterion. +4. **Config view is a TODO.** + `interactive/screens/main_screen.py:157` mounts + `Static("Configuration view - TODO")`. Replace it with a real configuration view + (show current `TUIConfig`: theme, sidebar/detail widths, etc.), or, if wiring a live + config editor is out of scope, at least render the current persisted config read-only. +5. **App integration.** Ensure the running app actually loads persisted config, applies + the theme, and restores window/column state on startup — per the phase doc's + "Main App integration" step. + +Work through the phase doc's **Implementation Order** (themes → config → config_manager → +window_manager → notifications → help_screen → doctor → tests → docs → app integration) +and **fill the gaps above**, not re-create what already exists. + +## 3. Acceptance criteria you must satisfy (from the phase doc) + +| Feature | Verification | +|---------|--------------| +| Themes work | Dark/light/custom switch correctly, colors update | +| Config persistence | Window size, **column widths**, filters saved/restored | +| Command palette | **Ctrl+P shows fuzzy searchable actions** | +| Help screen | **F1 shows the organized keyboard shortcuts** | +| Notifications | Success/warning/error/info messages appear appropriately | +| Doctor command | Runs all checks, shows clear pass/fail | +| Unit tests | >80% coverage on new/changed interactive components | +| Documentation | User guide covers installation, usage, troubleshooting | +| Cross-platform | All features work on Windows, Linux, macOS | + +## 4. Constraints & quality bar (from AGENTS.md) + +- Python 3.11+, `pathlib.Path` everywhere, async throughout. +- Use `structlog` for logging; use `datetime.now(timezone.utc)` — **never** `datetime.utcnow()`. +- Respect the layered architecture: `cli/ → interactive/ → core/services/ → core/`. +- Persistence JSON goes in `~/.tcrm/` (see `config_manager`/`window_manager` — reuse + them; don't fork a second persistence mechanism). +- Keep it simple. No special-case glue, no dead code. Prefer eliminating a branch over + guarding it. +- Cross-platform: no hardcoded path separators. + +## 5. Tests + +- Add/extend tests under `tests/unit/test_interactive.py` for the new/changed components + (command palette, help-screen launch, column-width save/restore, config persistence, + notifications). +- Prefer real code paths over mocks. For the palette/help dispatch, assert the correct + action was triggered. For persistence, write to a temp config dir and assert round-trip. +- Aim for >80% coverage on the interactive components you touch. + +## 6. Documentation + +- Update `docs/user-guide.md` (create if missing) with installation, usage, keyboard + shortcuts, themes, and troubleshooting. +- Add any new architecture decisions to `docs/plans/architecture-decisions.md`. + +## 7. Verify before you finish + +```bash +uv run pytest -v --tb=short # all passing (was 43 baseline; now includes new tests) +uv run ruff check . +uv run mypy tcrm_toolkit +uv run python scripts/verify-cross-platform.py +uv run tcrm --help # CLI still loads; commands registered +``` + +Manual checks (report results): +- `tcrm --interactive` → press **F1** → help screen opens. +- Ctrl+**P** → type "extract" → action dispatched. +- Change a column width in a browser, restart → width restored. +- `TCRM_TUI_THEME=light tcrm --interactive` → theme applied. + +If `ruff`/`mypy` report issues, fix them (lint/typecheck are non-blocking in CI, but ship +clean). Do not commit if the test suite is red. + +## 8. Report back + +Provide a concise summary: +- What you changed/added (file + one-line "what it does"). +- Which acceptance criteria you satisfied and the manual-check evidence. +- The final test/lint/typecheck/cross-platform results. +- Any deviations from the phase doc and why. +- Suggested next steps for **Phase 5** (parked value-add features: bulk ops, diff, + lineage, scheduling, plugins) — flag anything that Phase 4 should have prepared for. + +## Non-goals + +- Do not rework Phases 0–3. +- Do not implement Phase 5 (parked value-add features). +- Do not change the public CLI surface (command names/flags) unless a bug requires it. diff --git a/README.md b/README.md index 3cbf81e..d705e7a 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,96 @@ -### First things first: +# TCRM Toolkit -### This is a FOSS tool, not an official Salesforce or Tableau product. This toolkit hasn't been officially tested or documented by Salesforce or Tableau. Salesforce support is not available. Use at your own risk. It's provided as is and without any type of warranties. +Salesforce Tableau CRM (TCRM) Analytics Toolkit - A modern, async Python CLI for managing TCRM datasets, dashboards, and dataflows. -This toolkit has the purpose of expand the usability of TCRM. There are many tasks that are difficult to do using the UI like uploading CSVs or backing up data. The goal is to make those tasks easy to complete. +## Features -#### ---------------------------------------------------------------------------------------------------------------- +- **Async Architecture**: Built on `httpx` with automatic retries and circuit breakers +- **Secure Authentication**: Web PKCE, Device Authorization Flow, and JWT Bearer flows +- **Credential Security**: Dynamic salt encryption with OS keyring integration +- **Type Safety**: 100% typed with Pydantic models and mypy validation +- **Modern CLI**: Rich terminal UI with progress bars and formatted tables -### Dependencies: -You need to have Salesforce CLI installed. Get it from here: https://developer.salesforce.com/tools/sfdxcli +## Installation -#### Notes for Win10: -You need to install *Windows Terminal* from the Microsoft App Store. There are colors and functions in the app that won't work in command prompt. You can get it here: https://www.microsoft.com/en-us/p/windows-terminal/9n0dx20hk701 +```bash +# Clone the repository +git clone https://github.com/pg-dev-git/foss_analytics_toolkit +cd foss_analytics_toolkit -After installing Windows Terminal, reboot and now you should have an option to open the terminal when you right click inside a directory. -Navigate to the folder you extracted the tool, right click and launch Windows Terminal. -Then just launch TCRM_toolkit.exe from it. +# Install with uv (recommended) or pip +uv sync --extra dev +# or +pip install -e ".[dev]" +``` -Also, make sure you have the Visual C++ Redist installed. Get it from here: https://aka.ms/vs/16/release/vc_redist.x64.exe +## Configuration -### Python: -The recommended version of Python is 3.9 but you can use 3.8 too. Python 3.10 won't work at the moment. +Copy `.env.example` to `.env` and configure: -You can run "python3.9 -m pip install -r requirements.txt" to install the required dependencies for the tool to run properly. +```bash +cp .env.example .env +# Edit .env with your settings +``` -#### ---------------------------------------------------------------------------------------------------------------- +## Usage -### Compatibility: -This tool is able to run on Windows/Linux/MacOS without any issues. If you find a bug, please report it. +```bash +# Authenticate +tcrm auth login -#### ---------------------------------------------------------------------------------------------------------------- +# List datasets +tcrm datasets list -### At this time, the only date format supported when uploading CSV files is: yyyy/mm/dd. If another format is used, the field will be formatted as text. +# Extract dataset to CSV +tcrm datasets extract -#### ---------------------------------------------------------------------------------------------------------------- +# Upload CSV to dataset +tcrm datasets upload -## Login instructions +# List dashboards +tcrm dashboards list -There are two ways how to authenticate. Web Login and via a Connected app. The Web Login is the easier and recommended way. +# Backup dashboard +tcrm dashboards backup -You will need the server id from the Company Information section of your instance and also the domain name. +# List dataflows +tcrm dataflows list -### Instructions for Web Login: +# Start/stop dataflow +tcrm dataflows start +tcrm dataflows stop +``` -When you select this option on the console, enter your instance username and the server id. Your browser will open up the Salesforce login screen. Enter your credentials and you should be good to go. You can close the browser afterwards. *Make sure your user has a TCRM license and access to the Wave API* +## Architecture -### Instructions for Connected App: https://github.com/pg-dev-git/foss_analytics_toolkit/blob/master/conn-app.md +``` +tcrm_toolkit/ +├── core/ # Reusable SDK layer (UI-agnostic) +│ ├── config.py # Pydantic settings +│ ├── crypto.py # Encryption with dynamic salts +│ ├── client.py # Async HTTP client with retries +│ ├── models/ # Pydantic data models +│ └── services/ # Domain services (auth, dataset, dashboard, dataflow) +└── cli/ # Presentation layer only + ├── main.py # Typer entry point + ├── commands/ # CLI command implementations + └── ui.py # Rich formatting +``` -#### ---------------------------------------------------------------------------------------------------------------- +## Development -## Security +```bash +# Run tests +pytest -All config files will be encrypted with a password that you set up on the first run. If you forget the password, just delete the config files in the data folder and start from scratch. +# Type checking +mypy tcrm_toolkit -#### ---------------------------------------------------------------------------------------------------------------- +# Linting +ruff check tcrm_toolkit +ruff format tcrm_toolkit +``` -## Contact +## License -You can reach out via LinkedIn: https://www.linkedin.com/in/pedro-gagliardi-a9b95638/ -Or submit a PR here on GitHub - -#### ---------------------------------------------------------------------------------------------------------------- - -## Data Extraction and Upload Performance - -When this tool is not targeted to execute massive "ETL" jobs, it can perform decent extractions/uploads. -If you want to download big datasets, you will need a lot of RAM. -The following numbers were obtained on Windows Desktop with 16 cores and 32gb of RAM and a Ubuntu Desktop with 8 cores and 16gb of RAM. -The tool will automatically try to use disk space in case you run out of RAM but it could also help if you manually increase the size of your SWAP. - -![alt text](https://i.ibb.co/CMptHth/perf-table.jpg) - -![alt text](https://i.ibb.co/vQnwHNg/16.jpg) - -![alt text](https://i.ibb.co/kGtNx3g/32.jpg) +GNU Affero General Public License v3.0 \ No newline at end of file diff --git a/TASK_LOG.md b/TASK_LOG.md new file mode 100644 index 0000000..119687f --- /dev/null +++ b/TASK_LOG.md @@ -0,0 +1,26 @@ +# TASK LOG - Greenfield Rebuild of Salesforce TCRM Toolkit + +## Phase 1: Environment & Setup +- [x] 1.1 Create `_legacy/` folder and move legacy root files into it. +- [x] 1.2 Create and switch to Git branch `refactor/greenfield-cli`. +- [ ] 1.3 Scaffold `pyproject.toml` with dependencies (`typer`, `rich`, `httpx`, `pydantic`, `tenacity`, `cryptography`, `keyring`, `structlog`, `pandas`, `pytest`, `mypy`). + +## Phase 2: Core Foundation & Security +- [ ] 2.1 Build `core/config.py` using `pydantic-settings`. +- [ ] 2.2 Build `core/crypto.py` with dynamic salting and `keyring` integration. +- [ ] 2.3 Build `core/client.py` using `httpx.AsyncClient` with `tenacity` retry wrappers and configurable API versions. + +## Phase 3: Domain Models & Core Services +- [ ] 3.1 Implement Pydantic models in `core/models/` for Auth, Datasets, Dashboards, and Dataflows. +- [ ] 3.2 Build `core/services/auth_service.py` (Pure Python Web PKCE, Device Flow, JWT Bearer, and Auto-Refresh). +- [ ] 3.3 Build `core/services/dataset_service.py` (Async listing, CSV streaming, chunked multipart uploads). +- [ ] 3.4 Build `core/services/dashboard_service.py` (Listing, JSON backup/restore). +- [ ] 3.5 Build `core/services/dataflow_service.py` (List, start/stop dataflows, job status monitoring). + +## Phase 4: CLI Presentation Layer +- [ ] 4.1 Set up `cli/main.py` entry point with `typer` and `cli/ui.py` with `rich`. +- [ ] 4.2 Implement CLI commands in `cli/commands/` mapping user actions directly to `core/services/`. + +## Phase 5: Testing & Verification +- [ ] 5.1 Add unit tests for crypto, authentication schemas, and client retry logic under `tests/unit/`. +- [ ] 5.2 Add integration tests using `pytest-asyncio` and mocked API responses under `tests/integration/`. \ No newline at end of file diff --git a/_legacy/FOSS_Toolkit.py b/_legacy/FOSS_Toolkit.py new file mode 100644 index 0000000..3ba2c4c --- /dev/null +++ b/_legacy/FOSS_Toolkit.py @@ -0,0 +1,105 @@ +import json, requests, os, configparser, datetime, time, multiprocessing as mp +from misc_tasks.terminal_colors import * +from misc_tasks.sfdc_login import * +from dataset_tasks.get_datasets import * +from dataset_tasks.csv_new_dataset import * +from dataflow_tasks.get_dataflows import * +from data_manager_tasks.get_dataflowjobs import * +from misc_tasks.initial_checks import * +from dashboards_tasks.get_dashboards import * +from dashboards_tasks.mass_dashboard_backup import * +from misc_tasks.get_ea_limits import * +from misc_tasks.line import * +from dataflow_tasks.mass_dataflows_backup import * +from dataset_tasks.mass_user_xmd_backup import * + +if __name__ == "__main__": + + mp.freeze_support() + + d_ext = init_folders() + + os.chdir(d_ext) + + sfdc_login.intro() + + flag = "N" + + access_token,server_id,server_domain = sfdc_login.auth_check(flag) + + + #config = configparser.ConfigParser() + #config.read("{}".format(config_file)) + #server_id = config.get("DEFAULT", "server_id") + + run_token = True + while run_token: + line_print() + prGreen("What do you want to do?:") + time.sleep(0.15) + prYellow("(Choose a number from the list below)" + "\r\n") + time.sleep(0.2) + prCyan("1 - List datasets") + time.sleep(0.10) + prCyan("2 - List dashboards") + time.sleep(0.10) + prCyan("3 - List dataflows") + time.sleep(0.10) + prCyan("4 - List Data Manager jobs") + time.sleep(0.10) + prCyan("5 - Create New Dataset from CSV") + time.sleep(0.10) + prLightPurple("6 - Mass Backup all Dataflows") + time.sleep(0.10) + prLightPurple("7 - Mass Backup all Dashboards") + time.sleep(0.10) + prLightPurple("8 - Mass Backup all User XMDs") + time.sleep(0.10) + prYellow("9 - Check TCRM Limits") + time.sleep(0.10) + prYellow("10 - Run Login Parameters Configuration") + time.sleep(0.15) + + print("\r\n") + user_input = input("Enter your selection: ") + line_print() + + if user_input == "1": + get_datasets(access_token,server_id,server_domain) + + if user_input == "2": + get_dashboards_main(access_token,server_id,server_domain) + + if user_input == "3": + get_dataflows(access_token,server_id,server_domain) + + if user_input == "4": + get_dataflowsJobs(access_token,server_id,server_domain) + + if user_input == "5": + new_csv_dataset(access_token,server_id,server_domain) + + if user_input == "6": + mass_dataflows(access_token,server_id,server_domain) + + if user_input == "7": + mass_dashboards(access_token,server_id,server_domain) + + if user_input == "8": + mass_u_xmd_bkp(access_token,server_id,server_domain) + + if user_input == "9": + get_EA_limits(access_token,server_id) + + if user_input == "10": + flag = "Y" + sfdc_login.auth_check(flag) + flag = "N" + + + check_token = "Y" + + if check_token == "Y": + run_token = True + elif check_token == "N": + quit() diff --git a/_legacy/README.md b/_legacy/README.md new file mode 100644 index 0000000..3cbf81e --- /dev/null +++ b/_legacy/README.md @@ -0,0 +1,75 @@ +### First things first: + +### This is a FOSS tool, not an official Salesforce or Tableau product. This toolkit hasn't been officially tested or documented by Salesforce or Tableau. Salesforce support is not available. Use at your own risk. It's provided as is and without any type of warranties. + +This toolkit has the purpose of expand the usability of TCRM. There are many tasks that are difficult to do using the UI like uploading CSVs or backing up data. The goal is to make those tasks easy to complete. + +#### ---------------------------------------------------------------------------------------------------------------- + +### Dependencies: +You need to have Salesforce CLI installed. Get it from here: https://developer.salesforce.com/tools/sfdxcli + +#### Notes for Win10: +You need to install *Windows Terminal* from the Microsoft App Store. There are colors and functions in the app that won't work in command prompt. You can get it here: https://www.microsoft.com/en-us/p/windows-terminal/9n0dx20hk701 + +After installing Windows Terminal, reboot and now you should have an option to open the terminal when you right click inside a directory. +Navigate to the folder you extracted the tool, right click and launch Windows Terminal. +Then just launch TCRM_toolkit.exe from it. + +Also, make sure you have the Visual C++ Redist installed. Get it from here: https://aka.ms/vs/16/release/vc_redist.x64.exe + +### Python: +The recommended version of Python is 3.9 but you can use 3.8 too. Python 3.10 won't work at the moment. + +You can run "python3.9 -m pip install -r requirements.txt" to install the required dependencies for the tool to run properly. + +#### ---------------------------------------------------------------------------------------------------------------- + +### Compatibility: +This tool is able to run on Windows/Linux/MacOS without any issues. If you find a bug, please report it. + +#### ---------------------------------------------------------------------------------------------------------------- + +### At this time, the only date format supported when uploading CSV files is: yyyy/mm/dd. If another format is used, the field will be formatted as text. + +#### ---------------------------------------------------------------------------------------------------------------- + +## Login instructions + +There are two ways how to authenticate. Web Login and via a Connected app. The Web Login is the easier and recommended way. + +You will need the server id from the Company Information section of your instance and also the domain name. + +### Instructions for Web Login: + +When you select this option on the console, enter your instance username and the server id. Your browser will open up the Salesforce login screen. Enter your credentials and you should be good to go. You can close the browser afterwards. *Make sure your user has a TCRM license and access to the Wave API* + +### Instructions for Connected App: https://github.com/pg-dev-git/foss_analytics_toolkit/blob/master/conn-app.md + +#### ---------------------------------------------------------------------------------------------------------------- + +## Security + +All config files will be encrypted with a password that you set up on the first run. If you forget the password, just delete the config files in the data folder and start from scratch. + +#### ---------------------------------------------------------------------------------------------------------------- + +## Contact + +You can reach out via LinkedIn: https://www.linkedin.com/in/pedro-gagliardi-a9b95638/ +Or submit a PR here on GitHub + +#### ---------------------------------------------------------------------------------------------------------------- + +## Data Extraction and Upload Performance + +When this tool is not targeted to execute massive "ETL" jobs, it can perform decent extractions/uploads. +If you want to download big datasets, you will need a lot of RAM. +The following numbers were obtained on Windows Desktop with 16 cores and 32gb of RAM and a Ubuntu Desktop with 8 cores and 16gb of RAM. +The tool will automatically try to use disk space in case you run out of RAM but it could also help if you manually increase the size of your SWAP. + +![alt text](https://i.ibb.co/CMptHth/perf-table.jpg) + +![alt text](https://i.ibb.co/vQnwHNg/16.jpg) + +![alt text](https://i.ibb.co/kGtNx3g/32.jpg) diff --git a/_legacy/conn-app.md b/_legacy/conn-app.md new file mode 100644 index 0000000..5c6ed7e --- /dev/null +++ b/_legacy/conn-app.md @@ -0,0 +1,19 @@ +### Instructions to create a connected app and collect credentials: + +1 - Create a connected app: + +![alt text](https://i.ibb.co/Rbfn5N6/1.png) + +![alt text](https://i.ibb.co/gmw6GNv/2.png) + +2 - Edit your connected app IP Policies: + +![alt text](https://i.ibb.co/3T2TR2z/3.png) + +![alt text](https://i.ibb.co/8YYcwcP/4.png) + +3 - Collect your Consumer Key (Client ID) and Consumer Secret: + +![alt text](https://i.ibb.co/wzXw2VG/5.png) + +![alt text](https://i.ibb.co/fXhbPS5/6.png) diff --git a/dashboards_tasks/__pycache__/backup_dash_json.cpython-39.pyc b/_legacy/dashboards_tasks/__pycache__/backup_dash_json.cpython-39.pyc similarity index 100% rename from dashboards_tasks/__pycache__/backup_dash_json.cpython-39.pyc rename to _legacy/dashboards_tasks/__pycache__/backup_dash_json.cpython-39.pyc diff --git a/dashboards_tasks/__pycache__/delete_dashboard.cpython-39.pyc b/_legacy/dashboards_tasks/__pycache__/delete_dashboard.cpython-39.pyc similarity index 100% rename from dashboards_tasks/__pycache__/delete_dashboard.cpython-39.pyc rename to _legacy/dashboards_tasks/__pycache__/delete_dashboard.cpython-39.pyc diff --git a/dashboards_tasks/__pycache__/get_dash_datasets.cpython-39.pyc b/_legacy/dashboards_tasks/__pycache__/get_dash_datasets.cpython-39.pyc similarity index 100% rename from dashboards_tasks/__pycache__/get_dash_datasets.cpython-39.pyc rename to _legacy/dashboards_tasks/__pycache__/get_dash_datasets.cpython-39.pyc diff --git a/dashboards_tasks/__pycache__/get_dashboard_history.cpython-39.pyc b/_legacy/dashboards_tasks/__pycache__/get_dashboard_history.cpython-39.pyc similarity index 100% rename from dashboards_tasks/__pycache__/get_dashboard_history.cpython-39.pyc rename to _legacy/dashboards_tasks/__pycache__/get_dashboard_history.cpython-39.pyc diff --git a/dashboards_tasks/__pycache__/get_dashboards.cpython-39.pyc b/_legacy/dashboards_tasks/__pycache__/get_dashboards.cpython-39.pyc similarity index 100% rename from dashboards_tasks/__pycache__/get_dashboards.cpython-39.pyc rename to _legacy/dashboards_tasks/__pycache__/get_dashboards.cpython-39.pyc diff --git a/dashboards_tasks/__pycache__/mass_dashboard_backup.cpython-39.pyc b/_legacy/dashboards_tasks/__pycache__/mass_dashboard_backup.cpython-39.pyc similarity index 100% rename from dashboards_tasks/__pycache__/mass_dashboard_backup.cpython-39.pyc rename to _legacy/dashboards_tasks/__pycache__/mass_dashboard_backup.cpython-39.pyc diff --git a/dashboards_tasks/backup_dash_json.py b/_legacy/dashboards_tasks/backup_dash_json.py similarity index 100% rename from dashboards_tasks/backup_dash_json.py rename to _legacy/dashboards_tasks/backup_dash_json.py diff --git a/dashboards_tasks/delete_dashboard.py b/_legacy/dashboards_tasks/delete_dashboard.py similarity index 100% rename from dashboards_tasks/delete_dashboard.py rename to _legacy/dashboards_tasks/delete_dashboard.py diff --git a/dashboards_tasks/get_dash_datasets.py b/_legacy/dashboards_tasks/get_dash_datasets.py similarity index 100% rename from dashboards_tasks/get_dash_datasets.py rename to _legacy/dashboards_tasks/get_dash_datasets.py diff --git a/dashboards_tasks/get_dashboard_history.py b/_legacy/dashboards_tasks/get_dashboard_history.py similarity index 100% rename from dashboards_tasks/get_dashboard_history.py rename to _legacy/dashboards_tasks/get_dashboard_history.py diff --git a/dashboards_tasks/get_dashboards.py b/_legacy/dashboards_tasks/get_dashboards.py similarity index 100% rename from dashboards_tasks/get_dashboards.py rename to _legacy/dashboards_tasks/get_dashboards.py diff --git a/dashboards_tasks/mass_dashboard_backup.py b/_legacy/dashboards_tasks/mass_dashboard_backup.py similarity index 100% rename from dashboards_tasks/mass_dashboard_backup.py rename to _legacy/dashboards_tasks/mass_dashboard_backup.py diff --git a/dashboards_tasks/mp_dash_backup.py b/_legacy/dashboards_tasks/mp_dash_backup.py similarity index 100% rename from dashboards_tasks/mp_dash_backup.py rename to _legacy/dashboards_tasks/mp_dash_backup.py diff --git a/data_manager_tasks/__pycache__/get_dataflowjob_id.cpython-39.pyc b/_legacy/data_manager_tasks/__pycache__/get_dataflowjob_id.cpython-39.pyc similarity index 100% rename from data_manager_tasks/__pycache__/get_dataflowjob_id.cpython-39.pyc rename to _legacy/data_manager_tasks/__pycache__/get_dataflowjob_id.cpython-39.pyc diff --git a/data_manager_tasks/__pycache__/get_dataflowjobs.cpython-37.pyc b/_legacy/data_manager_tasks/__pycache__/get_dataflowjobs.cpython-37.pyc similarity index 100% rename from data_manager_tasks/__pycache__/get_dataflowjobs.cpython-37.pyc rename to _legacy/data_manager_tasks/__pycache__/get_dataflowjobs.cpython-37.pyc diff --git a/data_manager_tasks/__pycache__/get_dataflowjobs.cpython-39.pyc b/_legacy/data_manager_tasks/__pycache__/get_dataflowjobs.cpython-39.pyc similarity index 100% rename from data_manager_tasks/__pycache__/get_dataflowjobs.cpython-39.pyc rename to _legacy/data_manager_tasks/__pycache__/get_dataflowjobs.cpython-39.pyc diff --git a/data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-37.pyc b/_legacy/data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-37.pyc similarity index 100% rename from data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-37.pyc rename to _legacy/data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-37.pyc diff --git a/data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-39.pyc b/_legacy/data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-39.pyc similarity index 100% rename from data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-39.pyc rename to _legacy/data_manager_tasks/__pycache__/get_dataflowjobs_list.cpython-39.pyc diff --git a/data_manager_tasks/get_dataflowjob_id.py b/_legacy/data_manager_tasks/get_dataflowjob_id.py similarity index 100% rename from data_manager_tasks/get_dataflowjob_id.py rename to _legacy/data_manager_tasks/get_dataflowjob_id.py diff --git a/data_manager_tasks/get_dataflowjobs.py b/_legacy/data_manager_tasks/get_dataflowjobs.py similarity index 100% rename from data_manager_tasks/get_dataflowjobs.py rename to _legacy/data_manager_tasks/get_dataflowjobs.py diff --git a/data_manager_tasks/get_dataflowjobs_list.py b/_legacy/data_manager_tasks/get_dataflowjobs_list.py similarity index 100% rename from data_manager_tasks/get_dataflowjobs_list.py rename to _legacy/data_manager_tasks/get_dataflowjobs_list.py diff --git a/dataflow_tasks/__pycache__/backup_current.cpython-37.pyc b/_legacy/dataflow_tasks/__pycache__/backup_current.cpython-37.pyc similarity index 100% rename from dataflow_tasks/__pycache__/backup_current.cpython-37.pyc rename to _legacy/dataflow_tasks/__pycache__/backup_current.cpython-37.pyc diff --git a/dataflow_tasks/__pycache__/backup_current.cpython-39.pyc b/_legacy/dataflow_tasks/__pycache__/backup_current.cpython-39.pyc similarity index 100% rename from dataflow_tasks/__pycache__/backup_current.cpython-39.pyc rename to _legacy/dataflow_tasks/__pycache__/backup_current.cpython-39.pyc diff --git a/dataflow_tasks/__pycache__/get_dataflow_history.cpython-37.pyc b/_legacy/dataflow_tasks/__pycache__/get_dataflow_history.cpython-37.pyc similarity index 100% rename from dataflow_tasks/__pycache__/get_dataflow_history.cpython-37.pyc rename to _legacy/dataflow_tasks/__pycache__/get_dataflow_history.cpython-37.pyc diff --git a/dataflow_tasks/__pycache__/get_dataflow_history.cpython-39.pyc b/_legacy/dataflow_tasks/__pycache__/get_dataflow_history.cpython-39.pyc similarity index 100% rename from dataflow_tasks/__pycache__/get_dataflow_history.cpython-39.pyc rename to _legacy/dataflow_tasks/__pycache__/get_dataflow_history.cpython-39.pyc diff --git a/dataflow_tasks/__pycache__/get_dataflows.cpython-37.pyc b/_legacy/dataflow_tasks/__pycache__/get_dataflows.cpython-37.pyc similarity index 100% rename from dataflow_tasks/__pycache__/get_dataflows.cpython-37.pyc rename to _legacy/dataflow_tasks/__pycache__/get_dataflows.cpython-37.pyc diff --git a/dataflow_tasks/__pycache__/get_dataflows.cpython-39.pyc b/_legacy/dataflow_tasks/__pycache__/get_dataflows.cpython-39.pyc similarity index 100% rename from dataflow_tasks/__pycache__/get_dataflows.cpython-39.pyc rename to _legacy/dataflow_tasks/__pycache__/get_dataflows.cpython-39.pyc diff --git a/dataflow_tasks/__pycache__/mass_dataflows_backup.cpython-39.pyc b/_legacy/dataflow_tasks/__pycache__/mass_dataflows_backup.cpython-39.pyc similarity index 100% rename from dataflow_tasks/__pycache__/mass_dataflows_backup.cpython-39.pyc rename to _legacy/dataflow_tasks/__pycache__/mass_dataflows_backup.cpython-39.pyc diff --git a/dataflow_tasks/__pycache__/mp_df_backup.cpython-39.pyc b/_legacy/dataflow_tasks/__pycache__/mp_df_backup.cpython-39.pyc similarity index 100% rename from dataflow_tasks/__pycache__/mp_df_backup.cpython-39.pyc rename to _legacy/dataflow_tasks/__pycache__/mp_df_backup.cpython-39.pyc diff --git a/dataflow_tasks/__pycache__/start_dataflow.cpython-37.pyc b/_legacy/dataflow_tasks/__pycache__/start_dataflow.cpython-37.pyc similarity index 100% rename from dataflow_tasks/__pycache__/start_dataflow.cpython-37.pyc rename to _legacy/dataflow_tasks/__pycache__/start_dataflow.cpython-37.pyc diff --git a/dataflow_tasks/__pycache__/start_stop_dataflow.cpython-37.pyc b/_legacy/dataflow_tasks/__pycache__/start_stop_dataflow.cpython-37.pyc similarity index 100% rename from dataflow_tasks/__pycache__/start_stop_dataflow.cpython-37.pyc rename to _legacy/dataflow_tasks/__pycache__/start_stop_dataflow.cpython-37.pyc diff --git a/dataflow_tasks/__pycache__/start_stop_dataflow.cpython-39.pyc b/_legacy/dataflow_tasks/__pycache__/start_stop_dataflow.cpython-39.pyc similarity index 100% rename from dataflow_tasks/__pycache__/start_stop_dataflow.cpython-39.pyc rename to _legacy/dataflow_tasks/__pycache__/start_stop_dataflow.cpython-39.pyc diff --git a/dataflow_tasks/backup_current.py b/_legacy/dataflow_tasks/backup_current.py similarity index 100% rename from dataflow_tasks/backup_current.py rename to _legacy/dataflow_tasks/backup_current.py diff --git a/dataflow_tasks/get_dataflow_history.py b/_legacy/dataflow_tasks/get_dataflow_history.py similarity index 100% rename from dataflow_tasks/get_dataflow_history.py rename to _legacy/dataflow_tasks/get_dataflow_history.py diff --git a/dataflow_tasks/get_dataflows.py b/_legacy/dataflow_tasks/get_dataflows.py similarity index 100% rename from dataflow_tasks/get_dataflows.py rename to _legacy/dataflow_tasks/get_dataflows.py diff --git a/dataflow_tasks/mass_dataflows_backup.py b/_legacy/dataflow_tasks/mass_dataflows_backup.py similarity index 100% rename from dataflow_tasks/mass_dataflows_backup.py rename to _legacy/dataflow_tasks/mass_dataflows_backup.py diff --git a/dataflow_tasks/mp_df_backup.py b/_legacy/dataflow_tasks/mp_df_backup.py similarity index 100% rename from dataflow_tasks/mp_df_backup.py rename to _legacy/dataflow_tasks/mp_df_backup.py diff --git a/dataflow_tasks/start_stop_dataflow.py b/_legacy/dataflow_tasks/start_stop_dataflow.py similarity index 100% rename from dataflow_tasks/start_stop_dataflow.py rename to _legacy/dataflow_tasks/start_stop_dataflow.py diff --git a/dataset_tasks/MP_control.py b/_legacy/dataset_tasks/MP_control.py similarity index 100% rename from dataset_tasks/MP_control.py rename to _legacy/dataset_tasks/MP_control.py diff --git a/dataset_tasks/__pycache__/MP_control.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/MP_control.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/MP_control.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/MP_control.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/append_dataset.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/append_dataset.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/append_dataset.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/append_dataset.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/append_dataset.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/append_dataset.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/append_dataset.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/append_dataset.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/append_dataset_MT.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/append_dataset_MT.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/append_dataset_MT.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/append_dataset_MT.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/csv_new_MT.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/csv_new_MT.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/csv_new_MT.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/csv_new_MT.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/csv_new_dataset.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/csv_new_dataset.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/csv_new_dataset.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/csv_new_dataset.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_backup_main_xmd.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_backup_user_xmd.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/dataset_extract_MP.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/dataset_extract_MP.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_extract_MP.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_extract_MP.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/dataset_extract_MP.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/dataset_extract_MP.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_extract_MP.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_extract_MP.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/dataset_extract_MT.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/dataset_extract_MT.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_extract_MT.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_extract_MT.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/dataset_extract_MT.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/dataset_extract_MT.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/dataset_extract_MT.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/dataset_extract_MT.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/delete_dataset.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/delete_dataset.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/delete_dataset.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/delete_dataset.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/delete_dataset.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/delete_dataset.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/delete_dataset.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/delete_dataset.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_dependencies.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_dependencies.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_dependencies.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_dependencies.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_dependencies.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_dependencies.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_dependencies.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_dependencies.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_extract.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_extract.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_extract.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_extract.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_extract.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_extract.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_extract.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_extract.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_extract_MP.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_extract_v2.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_extract_v2.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_extract_v2.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_extract_v2.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_field_detail.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_field_detail.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_field_detail.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_field_detail.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_field_detail.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_field_detail.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_field_detail.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_field_detail.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_history.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_history.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_history.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_history.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/get_dataset_history.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/get_dataset_history.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_dataset_history.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/get_dataset_history.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/get_datasets.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/get_datasets.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_datasets.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/get_datasets.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/get_datasets.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/get_datasets.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/get_datasets.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/get_datasets.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/json_metadata_generator.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/json_metadata_generator.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/json_metadata_generator.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/json_metadata_generator.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/json_metadata_generator.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/json_metadata_generator.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/json_metadata_generator.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/json_metadata_generator.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/mass_user_xmd_backup.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/mass_user_xmd_backup.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/mass_user_xmd_backup.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/mass_user_xmd_backup.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/mt_for_mp.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/mt_for_mp.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/mt_for_mp.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/mt_for_mp.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/mt_for_mp.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/mt_for_mp.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/mt_for_mp.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/mt_for_mp.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/new_csv_dataset.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/new_csv_dataset.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/new_csv_dataset.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/new_csv_dataset.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/new_csv_dataset.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/new_csv_dataset.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/new_csv_dataset.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/new_csv_dataset.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/upload_dataset.cpython-37.pyc b/_legacy/dataset_tasks/__pycache__/upload_dataset.cpython-37.pyc similarity index 100% rename from dataset_tasks/__pycache__/upload_dataset.cpython-37.pyc rename to _legacy/dataset_tasks/__pycache__/upload_dataset.cpython-37.pyc diff --git a/dataset_tasks/__pycache__/upload_dataset.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/upload_dataset.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/upload_dataset.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/upload_dataset.cpython-39.pyc diff --git a/dataset_tasks/__pycache__/xmd_cleanup.cpython-39.pyc b/_legacy/dataset_tasks/__pycache__/xmd_cleanup.cpython-39.pyc similarity index 100% rename from dataset_tasks/__pycache__/xmd_cleanup.cpython-39.pyc rename to _legacy/dataset_tasks/__pycache__/xmd_cleanup.cpython-39.pyc diff --git a/dataset_tasks/append_dataset.py b/_legacy/dataset_tasks/append_dataset.py similarity index 100% rename from dataset_tasks/append_dataset.py rename to _legacy/dataset_tasks/append_dataset.py diff --git a/dataset_tasks/append_dataset_MT.py b/_legacy/dataset_tasks/append_dataset_MT.py similarity index 100% rename from dataset_tasks/append_dataset_MT.py rename to _legacy/dataset_tasks/append_dataset_MT.py diff --git a/dataset_tasks/csv_new_MT.py b/_legacy/dataset_tasks/csv_new_MT.py similarity index 100% rename from dataset_tasks/csv_new_MT.py rename to _legacy/dataset_tasks/csv_new_MT.py diff --git a/dataset_tasks/csv_new_dataset.py b/_legacy/dataset_tasks/csv_new_dataset.py similarity index 100% rename from dataset_tasks/csv_new_dataset.py rename to _legacy/dataset_tasks/csv_new_dataset.py diff --git a/dataset_tasks/dataset_backup_main_xmd.py b/_legacy/dataset_tasks/dataset_backup_main_xmd.py similarity index 100% rename from dataset_tasks/dataset_backup_main_xmd.py rename to _legacy/dataset_tasks/dataset_backup_main_xmd.py diff --git a/dataset_tasks/dataset_backup_system_xmd.py b/_legacy/dataset_tasks/dataset_backup_system_xmd.py similarity index 100% rename from dataset_tasks/dataset_backup_system_xmd.py rename to _legacy/dataset_tasks/dataset_backup_system_xmd.py diff --git a/dataset_tasks/dataset_backup_user_xmd.py b/_legacy/dataset_tasks/dataset_backup_user_xmd.py similarity index 100% rename from dataset_tasks/dataset_backup_user_xmd.py rename to _legacy/dataset_tasks/dataset_backup_user_xmd.py diff --git a/dataset_tasks/dataset_extract_MP.py b/_legacy/dataset_tasks/dataset_extract_MP.py similarity index 100% rename from dataset_tasks/dataset_extract_MP.py rename to _legacy/dataset_tasks/dataset_extract_MP.py diff --git a/dataset_tasks/dataset_extract_MT.py b/_legacy/dataset_tasks/dataset_extract_MT.py similarity index 100% rename from dataset_tasks/dataset_extract_MT.py rename to _legacy/dataset_tasks/dataset_extract_MT.py diff --git a/dataset_tasks/delete_dataset.py b/_legacy/dataset_tasks/delete_dataset.py similarity index 100% rename from dataset_tasks/delete_dataset.py rename to _legacy/dataset_tasks/delete_dataset.py diff --git a/dataset_tasks/get_dataset_dependencies.py b/_legacy/dataset_tasks/get_dataset_dependencies.py similarity index 100% rename from dataset_tasks/get_dataset_dependencies.py rename to _legacy/dataset_tasks/get_dataset_dependencies.py diff --git a/dataset_tasks/get_dataset_extract.py b/_legacy/dataset_tasks/get_dataset_extract.py similarity index 100% rename from dataset_tasks/get_dataset_extract.py rename to _legacy/dataset_tasks/get_dataset_extract.py diff --git a/dataset_tasks/get_dataset_extract_MP.py b/_legacy/dataset_tasks/get_dataset_extract_MP.py similarity index 100% rename from dataset_tasks/get_dataset_extract_MP.py rename to _legacy/dataset_tasks/get_dataset_extract_MP.py diff --git a/dataset_tasks/get_dataset_extract_v2.py b/_legacy/dataset_tasks/get_dataset_extract_v2.py similarity index 100% rename from dataset_tasks/get_dataset_extract_v2.py rename to _legacy/dataset_tasks/get_dataset_extract_v2.py diff --git a/dataset_tasks/get_dataset_field_detail.py b/_legacy/dataset_tasks/get_dataset_field_detail.py similarity index 100% rename from dataset_tasks/get_dataset_field_detail.py rename to _legacy/dataset_tasks/get_dataset_field_detail.py diff --git a/dataset_tasks/get_dataset_history.py b/_legacy/dataset_tasks/get_dataset_history.py similarity index 100% rename from dataset_tasks/get_dataset_history.py rename to _legacy/dataset_tasks/get_dataset_history.py diff --git a/dataset_tasks/get_datasets.py b/_legacy/dataset_tasks/get_datasets.py similarity index 100% rename from dataset_tasks/get_datasets.py rename to _legacy/dataset_tasks/get_datasets.py diff --git a/dataset_tasks/json_metadata_generator.py b/_legacy/dataset_tasks/json_metadata_generator.py similarity index 100% rename from dataset_tasks/json_metadata_generator.py rename to _legacy/dataset_tasks/json_metadata_generator.py diff --git a/dataset_tasks/mass_user_xmd_backup.py b/_legacy/dataset_tasks/mass_user_xmd_backup.py similarity index 100% rename from dataset_tasks/mass_user_xmd_backup.py rename to _legacy/dataset_tasks/mass_user_xmd_backup.py diff --git a/dataset_tasks/mt_for_mp.py b/_legacy/dataset_tasks/mt_for_mp.py similarity index 100% rename from dataset_tasks/mt_for_mp.py rename to _legacy/dataset_tasks/mt_for_mp.py diff --git a/dataset_tasks/new_csv_dataset.py b/_legacy/dataset_tasks/new_csv_dataset.py similarity index 100% rename from dataset_tasks/new_csv_dataset.py rename to _legacy/dataset_tasks/new_csv_dataset.py diff --git a/dataset_tasks/upload_dataset.py b/_legacy/dataset_tasks/upload_dataset.py similarity index 100% rename from dataset_tasks/upload_dataset.py rename to _legacy/dataset_tasks/upload_dataset.py diff --git a/dataset_tasks/xmd_cleanup.py b/_legacy/dataset_tasks/xmd_cleanup.py similarity index 100% rename from dataset_tasks/xmd_cleanup.py rename to _legacy/dataset_tasks/xmd_cleanup.py diff --git a/misc_tasks/__pycache__/b2h.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/b2h.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/b2h.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/b2h.cpython-39.pyc diff --git a/misc_tasks/__pycache__/crypt.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/crypt.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/crypt.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/crypt.cpython-39.pyc diff --git a/misc_tasks/__pycache__/get_ea_limits.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/get_ea_limits.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/get_ea_limits.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/get_ea_limits.cpython-39.pyc diff --git a/misc_tasks/__pycache__/initial_checks.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/initial_checks.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/initial_checks.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/initial_checks.cpython-39.pyc diff --git a/misc_tasks/__pycache__/line.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/line.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/line.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/line.cpython-39.pyc diff --git a/misc_tasks/__pycache__/mp_dash_backup.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/mp_dash_backup.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/mp_dash_backup.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/mp_dash_backup.cpython-39.pyc diff --git a/misc_tasks/__pycache__/sfdc_login.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/sfdc_login.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/sfdc_login.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/sfdc_login.cpython-39.pyc diff --git a/misc_tasks/__pycache__/system_metrics.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/system_metrics.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/system_metrics.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/system_metrics.cpython-39.pyc diff --git a/misc_tasks/__pycache__/terminal_colors.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/terminal_colors.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/terminal_colors.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/terminal_colors.cpython-39.pyc diff --git a/misc_tasks/__pycache__/zipper.cpython-39.pyc b/_legacy/misc_tasks/__pycache__/zipper.cpython-39.pyc similarity index 100% rename from misc_tasks/__pycache__/zipper.cpython-39.pyc rename to _legacy/misc_tasks/__pycache__/zipper.cpython-39.pyc diff --git a/misc_tasks/b2h.py b/_legacy/misc_tasks/b2h.py similarity index 100% rename from misc_tasks/b2h.py rename to _legacy/misc_tasks/b2h.py diff --git a/misc_tasks/crypt.py b/_legacy/misc_tasks/crypt.py similarity index 100% rename from misc_tasks/crypt.py rename to _legacy/misc_tasks/crypt.py diff --git a/misc_tasks/get_ea_limits.py b/_legacy/misc_tasks/get_ea_limits.py similarity index 100% rename from misc_tasks/get_ea_limits.py rename to _legacy/misc_tasks/get_ea_limits.py diff --git a/misc_tasks/initial_checks.py b/_legacy/misc_tasks/initial_checks.py similarity index 100% rename from misc_tasks/initial_checks.py rename to _legacy/misc_tasks/initial_checks.py diff --git a/misc_tasks/intro.py b/_legacy/misc_tasks/intro.py similarity index 100% rename from misc_tasks/intro.py rename to _legacy/misc_tasks/intro.py diff --git a/misc_tasks/line.py b/_legacy/misc_tasks/line.py similarity index 100% rename from misc_tasks/line.py rename to _legacy/misc_tasks/line.py diff --git a/misc_tasks/main.py b/_legacy/misc_tasks/main.py similarity index 100% rename from misc_tasks/main.py rename to _legacy/misc_tasks/main.py diff --git a/misc_tasks/mass_dashboard_backup.py b/_legacy/misc_tasks/mass_dashboard_backup.py similarity index 100% rename from misc_tasks/mass_dashboard_backup.py rename to _legacy/misc_tasks/mass_dashboard_backup.py diff --git a/misc_tasks/mp_dash_backup.py b/_legacy/misc_tasks/mp_dash_backup.py similarity index 100% rename from misc_tasks/mp_dash_backup.py rename to _legacy/misc_tasks/mp_dash_backup.py diff --git a/misc_tasks/sfdc_login.py b/_legacy/misc_tasks/sfdc_login.py similarity index 100% rename from misc_tasks/sfdc_login.py rename to _legacy/misc_tasks/sfdc_login.py diff --git a/misc_tasks/system_metrics.py b/_legacy/misc_tasks/system_metrics.py similarity index 100% rename from misc_tasks/system_metrics.py rename to _legacy/misc_tasks/system_metrics.py diff --git a/misc_tasks/terminal_colors.py b/_legacy/misc_tasks/terminal_colors.py similarity index 100% rename from misc_tasks/terminal_colors.py rename to _legacy/misc_tasks/terminal_colors.py diff --git a/misc_tasks/zipper.py b/_legacy/misc_tasks/zipper.py similarity index 100% rename from misc_tasks/zipper.py rename to _legacy/misc_tasks/zipper.py diff --git a/_legacy/requirements.txt b/_legacy/requirements.txt new file mode 100644 index 0000000..e8e4d78 --- /dev/null +++ b/_legacy/requirements.txt @@ -0,0 +1,4 @@ +modin[ray] +psutil +requests +cryptography diff --git a/_legacy/toolkit_data/auth_method.ini b/_legacy/toolkit_data/auth_method.ini new file mode 100644 index 0000000..cdc7010 --- /dev/null +++ b/_legacy/toolkit_data/auth_method.ini @@ -0,0 +1,3 @@ +[DEFAULT] +method = 999 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..489eba4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: '3.8' + +services: + dev: + build: + context: . + dockerfile: Dockerfile + target: builder + container_name: crma-dev + volumes: + - .:/app:cached + - ~/.ssh:/home/tcrm/.ssh:ro + - ~/.gitconfig:/home/tcrm/.gitconfig:ro + environment: + - DISPLAY=${DISPLAY} + - WAYLAND_DISPLAY=${WAYLAND_DISPLAY} + - XDG_RUNTIME_DIR=/run/user/${UID} + - TERM=xterm-256color + stdin_open: true + tty: true + network_mode: "host" + working_dir: /app + command: sleep infinity + + prod: + build: + context: . + dockerfile: Dockerfile + target: runtime + container_name: crma-prod + volumes: + - tcrm-data:/home/tcrm/.local/share/tcrm + - tcrm-config:/home/tcrm/.config/tcrm + stdin_open: true + tty: true + network_mode: "host" + +volumes: + tcrm-data: + tcrm-config: diff --git a/docs/plans/architecture-decisions.md b/docs/plans/architecture-decisions.md new file mode 100644 index 0000000..9bd4701 --- /dev/null +++ b/docs/plans/architecture-decisions.md @@ -0,0 +1,371 @@ +# Architecture Decision Log + +This document records key architectural decisions made during the development of the CRM Toolkit Interactive TUI. + +## Decision Format + +Each decision follows this structure: +- **ID**: Unique identifier (ADR-001, ADR-002, etc.) +- **Title**: Short, descriptive title +- **Status**: Proposed | Accepted | Superseded | Deprecated +- **Context**: The problem or situation that led to this decision +- **Decision**: The chosen solution +- **Consequences**: Positive, negative, and neutral effects +- **Related Decisions**: Any decisions that are related or affected + +--- + +## ADR-001: Use Textual for TUI Framework +- **Status**: Accepted +- **Context**: Need a modern, async TUI framework that integrates well with existing async codebase +- **Decision**: Use Textual (by Textualize) as the TUI framework +- **Consequences**: + - + Modern, React-like component model + - + CSS-based styling and theming + - + Excellent async support + - + Active development and community + - - Learning curve for team unfamiliar with Textual + - - Additional dependency (~2MB) +- **Related Decisions**: ADR-002 (CSS theming), ADR-006 (Widget composition) + +--- + +## ADR-002: CSS-Based Theming System +- **Status**: Accepted +- **Context**: Need to support dark/light themes and customization +- **Decision**: Use CSS files for theming with CSS variables for easy customization +- **Consequences**: + - + Separation of concerns (structure vs styling) + - + Easy theme switching at runtime + - + Support for custom themes via CSS overrides + - + Familiar to web developers + - - Requires maintaining multiple CSS files + - - Slightly larger CSS payload +- **Related Decisions**: ADR-001 (Textual framework), ADR-004 (Configuration persistence) + +--- + +## ADR-003: SF CLI Authentication as Mandatory Default +- **Status**: Accepted +- **Context**: User requires SF CLI auth as primary method due to VPN/Proxy restrictions and ease of use +- **Decision**: Make SF CLI authentication the default and recommended method, with other auth methods (JWT, Web PKCE, Device) available as CLI-only options +- **Consequences**: + - + No Connected App required for users + - + Web flow is user-friendly + - + Token storage and auto-refresh handled by SF CLI + - + Works reliably across networks + - - Requires SF CLI installation + - - Less flexible for server-to-server automation (though JWT still available) +- **Related Decisions**: ADR-009 (Safety monitor), ADR-012 (Session manager) + +--- + +## ADR-004: Pydantic Settings for Configuration +- **Status**: Accepted +- **Context**: Need type-safe, environment-variable configurable settings for TUI +- **Decision**: Use Pydantic Settings with env var prefix `TCRM_TUI_` for all TUI-specific configuration +- **Consequences**: + - + Type safety and validation + - + Automatic env var loading + - + Good error messages + - + Integration with Pydantic models throughout codebase + - - Slight learning curve for Pydantic + - - Dependency on pydantic-settings +- **Related Decisions**: ADR-002 (Theming), ADR-005 (Config persistence), ADR-006 (TUI Config model) + +--- + +## ADR-005: JSON File Persistence in ~/.tcrm/ +- **Status**: Accepted +- **Context**: Need to persist TUI configuration, window state, column preferences, and history +- **Decision**: Store all persistent data in JSON files under `~/.tcrm/` directory +- **Consequences**: + - + Human-readable and editable + - + Easy to backup/migrate + - + Works across all platforms (Windows/Linux/macOS) + - + Simple implementation + - - Potential for corruption if written during crash + - - No built-in querying capabilities +- **Related Decisions**: ADR-004 (Pydantic Settings), ADR-006 (Config Manager), ADR-007 (Window Manager) + +--- + +## ADR-006: Generic DataBrowser Widget +- **Status**: Accepted +- **Context**: Need reusable component for browsing datasets, dashboards, dataflows with common features (search, sort, paginate, select) +- **Decision**: Create a generic `DataBrowser` widget parameterized by column configuration and data loading functions +- **Consequences**: + - + DRY principle - single implementation for all browsers + - + Easy to add new browser types + - + Consistent UX across all data views + - - Slightly more abstract than concrete implementations + - - Requires careful interface design +- **Related Decisions**: ADR-007 (Dataset operations), ADR-008 (Dashboard operations), ADR-009 (Dataflow operations) + +--- + +## ADR-007: Client-Side Pagination for Data Browsers +- **Status**: Accepted +- **Context**: Existing service layer doesn't support server-side pagination with offset/limit +- **Decision**: Implement client-side pagination on loaded data (fetch page_size=1000, then paginate client-side) +- **Consequences**: + - + Works with existing service layer + - + Fast for <5000 items + - + Simple implementation + - - Memory usage grows with total items + - - Network overhead for fetching all items + - - Not suitable for >10,000 items without service enhancement +- **Related Decisions**: ADR-006 (DataBrowser), ADR-010 (Future service enhancements) + +--- + +## ADR-008: ProcessPoolExecutor for CPU-Bound Work +- **Status**: Accepted +- **Context**: Need to handle CPU-intensive operations (pandas concat, CSV processing) without blocking UI +- **Decision**: Use `ProcessPoolExecutor` from `concurrent.futures` for CPU-bound work, asyncio for I/O-bound work +- **Consequences**: + - + Utilizes multiple cores for CPU-intensive tasks + - + Prevents UI freezing during long operations + - + Matches legacy multiprocessing performance + - - Serialization/deserialization overhead + - - Only works with picklable functions + - - Memory duplication between processes +- **Related Decisions**: ADR-010 (TaskRunner), ADR-011 (Parallel extraction/upload) + +--- + +## ADR-009: Connection Safety Monitor with Hard Block +- **Status**: Accepted +- **Context**: Salesforce now immediately disables users detected on VPN/Proxy - career ending risk +- **Decision**: Implement background safety monitor that checks for VPN/Proxy/Tor and hard blocks Salesforce API calls when critical risk detected +- **Consequences**: + - + Protects users from accidental org lockout + - + Runs continuously in background + - + Provides clear warnings and modal dialogs + - - Potential for false positives (mitigated by allowlist) + - - Slight performance overhead from periodic checks +- **Related Decisions**: ADR-003 (SF CLI Auth), ADR-012 (Session Manager integration), ADR-013 (Safety modals) + +--- + +## ADR-010: TaskRunner with Progress Tracking +- **Status**: Accepted +- **Context**: Need to run background operations with progress updates and cancellation support +- **Decision**: Create `TaskRunner` class that manages async tasks, provides progress messages, and integrates with ProcessPoolExecutor for CPU work +- **Consequences**: + - + Non-blocking long operations + - + Real-time progress updates to UI + - + Cancellation support + - + Task history and persistence + - - Increased complexity + - - Need to handle task lifecycle carefully +- **Related Decisions**: ADR-008 (ProcessPool), ADR-011 (Parallel ops), ADR-014 (Progress panel) + +--- + +## ADR-011: Parallel Dataset Extraction/Upload +- **Status**: Accepted +- **Context**: Need to match or exceed legacy multiprocessing performance for large dataset operations +- **Decision**: Implement parallel extraction (async SAQL queries + process pool merge) and parallel upload (process pool chunk encoding + sequential upload) +- **Consequences**: + - + Significant performance improvement for large datasets + - + Efficient use of system resources (I/O async, CPU parallel) + - + Progress tracking throughout + - - Complexity in implementation + - - Temporary disk space for chunks + - - Need for careful error handling and cleanup +- **Related Decisions**: ADR-008 (ProcessPool), ADR-010 (TaskRunner), ADR-013 (Operations integration) + +--- + +## ADR-012: SessionManager Wraps SFCLIAuthService +- **Status**: Accepted +- **Context**: Need to manage authenticated sessions across TUI lifecycle with multi-org support +- **Decision**: Create `SessionManager` that wraps `SFCLIAuthService` to provide org switching, token caching, and safety integration +- **Consequences**: + - + Clean separation of concerns + - + Reusable across TUI and potential CLI usage + - + Handles multi-org seamlessly + - + Integrates with safety monitor + - - Small abstraction layer + - - Slight indirection +- **Related Decisions**: ADR-003 (SF CLI Auth), ADR-009 (Safety), ADR-013 (Main app integration) + +--- + +## ADR-013: Modal Screens for Transient Interactions +- **Status**: Accepted +- **Context**: Need for login, org picker, safety warnings, and help screens that appear temporarily +- **Decision**: Use Textual `ModalScreen` for all transient interactive screens +- **Consequences**: + - + Clean separation from main UI + - + Automatic focus management + - + Consistent presentation + - + Easy to dismiss/return + - - Slight overhead of screen stack management +- **Related Decisions**: ADR-001 (Textual), ADR-003 (Login), ADR-004 (Org picker), ADR-009 (Safety modal), ADR-014 (Help screen) + +--- + +## ADR-014: Tabbed Interface for Help and History +- **Status**: Accepted +- **Context**: Need to organize large amounts of information (help, history) in limited screen space +- **Decision**: Use `TabbedContent` and `TabPane` for organizing help screens, history views, and other tabbed information +- **Consequences**: + - + Efficient use of screen real estate + - + Familiar UI pattern + - + Easy to add/remove tabs + - - Requires adequate tab labels + - - Hidden content until tab selected +- **Related Decisions**: ADR-006 (Generic components), ADR-015 (Help screen), ADR-016 (History panel) + +--- + +## ADR-015: Notification Manager with History and Severity +- **Status**: Accepted +- **Context**: Need consistent, user-friendly notifications with history and severity levels +- **Decision**: Create `NotificationManager` wrapper around `app.notify()` that adds history tracking, severity levels, and sticky notifications +- **Consequences**: + - + Consistent user experience + - + Notification history for debugging + - + Configurable timeout and stickiness + - + Integration with logging + - - Slight abstraction over built-in notifications +- **Related Decisions**: ADR-004 (Configuration), ADR-016 (Doctor command), ADR-017 (Error handling) + +--- + +## ADR-016: Enhanced Doctor Command +- **Status**: Accepted +- **Context**: Need comprehensive system diagnostics that include TUI-specific checks +- **Decision**: Enhance `tcrm doctor` command to run all system checks in parallel and display results in a clear table format +- **Consequences**: + - + Comprehensive system validation + - + Parallel execution for speed + - + Clear pass/fail reporting + - + Includes TUI-specific checks (themes, config, safety) + - - Longer execution time than basic doctor + - - Potential for cascading failures +- **Related Decisions**: ADR-009 (Safety check), ADR-003 (SF CLI), ADR-004 (Dependencies), ADR-015 (Notifications) + +--- + +## ADR-017: Centralized Error Handling with User Messages +- **Status**: Accepted +- **Context**: Need to present technical errors in user-friendly way while logging details for debugging +- **Decision**: Implement centralized error handling that logs technical details and shows user-friendly messages with suggested actions +- **Consequences**: + - + Better user experience + - + Consistent error presentation + - + Logging preserved for debugging + - + Actionable guidance for users + - - Requires discipline to use consistently + - - Potential for over-abstraction +- **Related Decisions**: ADR-015 (Notifications), ADR-010 (TaskRunner errors), ADR-018 (Validation) + +--- + +## ADR-018: Input Validation and Sanitization +- **Status**: Accepted +- **Context**: Need to protect against injection attacks and malformed input while maintaining usability +- **Decision**: Implement validation at boundaries (API inputs, file paths, user input) with sanitization where appropriate +- **Consequences**: + - + Improved security + - + Better data quality + - + Clear error messages for invalid input + - - Slight performance overhead + - - Potential for over-validation +- **Related Decisions**: ADR-017 (Error handling), ADR-006 (DataBrowser validation), ADR-011 (Operation validation) + +--- + +## ADR-019: Internationalization Ready (Future) +- **Status**: Proposed +- **Context**: Potential future need for multi-language support +- **Decision**: Design all user-facing strings to be easily extractable for i18n, but don't implement full i18n in MVP +- **Consequences**: + - + Easy to add i18n later + - + Minimal overhead now + - - Slightly more verbose string handling + - - No actual translations in MVP +- **Related Decisions**: ADR-002 (Theming - could extend to RTL), ADR-014 (Help screen), ADR-015 (Notifications) + +--- + +## ADR-020: Plugin System Architecture (Future) +- **Status**: Proposed +- **Context**: Need for extensibility to support custom operations and integrations +- **Decision**: Design plugin system using `importlib.metadata` entry points with well-defined hooks and permissions +- **Consequences**: + - + Enables community contributions + - + Allows customization without fork + - + Clear extension points + - - Security considerations + - - Complexity in implementation + - - Versioning challenges +- **Related Decisions**: ADR-001 (Textual - could extend widgets), ADR-010 (TaskRunner - background ops), ADR-013 (Modal screens - plugin UI) + +--- + +## ADR-021: Use `uv` for Dependency Management in Docker +- **Status**: Accepted +- **Context**: Fast, reliable dependency installation required in container builds +- **Decision**: Use Astral `uv` in multi-stage Docker builds +- **Consequences**: + - + Extremely fast dependency resolution and installation + - + Consistent python environment reproduction + - - Requires uv binary in builder stage +- **Related Decisions**: ADR-004 (Dependencies) + +--- + +## ADR-022: Multi-stage Docker Build for Minimal Runtime Image +- **Status**: Accepted +- **Context**: Need lightweight production images without build tooling bloat +- **Decision**: Use a multi-stage Docker build separating builder and runtime environments +- **Consequences**: + - + Smaller final image size + - + Improved security surface area + - - Slightly more complex Dockerfile +- **Related Decisions**: ADR-021 (uv) + +--- + +## ADR-023: Non-Root User in Docker for Security +- **Status**: Accepted +- **Context**: Running containers as root poses security risks +- **Decision**: Create and run container processes as non-root user `tcrm` +- **Consequences**: + - + Adheres to container security best practices + - + Prevents host privilege escalation vulnerabilities + - - Requires careful file permission setup for config/data dirs +- **Related Decisions**: ADR-005 (Config paths) + +--- + +## ADR-024: Host Network Mode for SF CLI Localhost Callbacks +- **Status**: Accepted +- **Context**: SF CLI web login flows require callback on `http://localhost:*` +- **Decision**: Use `network_mode: "host"` in Docker configuration for development/runtime containers +- **Consequences**: + - + Seamless SF CLI web login callbacks inside Docker + - + No port mapping conflicts + - - Docker networking isolation bypassed for host interface +- **Related Decisions**: ADR-003 (SF CLI Auth) + +--- + +## ADR-025: `pathlib.Path` and Platform Utils for Cross-Platform Support +- **Status**: Accepted +- **Context**: Codebase must run seamlessly on Windows, Linux, and macOS +- **Decision**: Use `pathlib.Path` exclusively and a centralized `platform.py` utility for OS-specific paths (`~/.config`, `AppData`, etc.) +- **Consequences**: + - + Native OS path compatibility without manual string manipulation + - + Standardized configuration and data directories per OS standards + - - Requires discipline across all modules +- **Related Decisions**: ADR-005 (JSON File Persistence) + +--- + +*Last updated: 2026-07-23* +*This document is append-only - never modify or delete existing entries.* \ No newline at end of file diff --git a/docs/plans/phases/README.md b/docs/plans/phases/README.md new file mode 100644 index 0000000..e2713bb --- /dev/null +++ b/docs/plans/phases/README.md @@ -0,0 +1,120 @@ +# CRM Toolkit Interactive TUI - Implementation Plans + +**Master Index** — Phase-by-phase implementation documents for the Interactive TUI with VPN/Proxy Safety Monitor. + +--- + +## 📋 Phase Overview + +| Phase | Document | Focus | Est. Duration | Status | +|-------|----------|-------|---------------|--------| +| **0** | [phase-0-foundation-setup.md](phase-0-foundation-setup.md) | Project setup, dependencies, Docker, cross-platform config | 2-3 days | ✅ Completed | +| **1** | [phase-1-core-infrastructure.md](phase-1-core-infrastructure.md) | SessionManager, SafetyMonitor, Textual App skeleton, SF CLI auth integration | 1 week | ✅ Completed | +| **2** | [phase-2-navigation-browsers.md](phase-2-navigation-browsers.md) | Dataset/Dashboard/Dataflow browsers, search/filter/sort, detail panels | 1 week | ✅ Completed | +| **3** | [phase-3-operations-background-tasks.md](phase-3-operations-background-tasks.md) | TaskRunner, parallel dataset extract/upload, dashboard backup, dataflow control | 1 week | ✅ Completed | +| **4** | [phase-4-polish-dx.md](phase-4-polish-dx.md) | Themes, config persistence, command palette, tests, docs, doctor command | 1 week | ⏳ Pending | +| **5** | [phase-5-value-add-future.md](phase-5-value-add-future.md) | Parked features: bulk ops, diff, lineage, scheduling, plugins | Future | ⏳ Parked | + +--- + +## 🎯 Cross-Cutting Requirements (All Phases) + +### OS-Agnostic Design +- **Native Python**: All code must run on Windows, Linux, macOS without modification +- **Path handling**: Use `pathlib.Path` exclusively, never hardcode separators +- **Process management**: Use `asyncio.subprocess` / `concurrent.futures.ProcessPoolExecutor` (not `multiprocessing` directly) +- **Terminal detection**: Handle Windows Console vs ANSI terminals (Textual handles this) +- **Keyring**: Use `keyring` backend auto-detection (works on all OSes) +- **SF CLI paths**: Already handled in `tcrm_toolkit/core/sf_cli.py` — extend as needed + +### Docker Support (Optional but Recommended) +```dockerfile +# Multi-stage build for minimal production image +# Base: python:3.12-slim +# Install: SF CLI, textual, dependencies +# Entry: tcrm (interactive TUI) +``` +- **Dockerfile** in repo root +- **docker-compose.yml** for dev environment +- **GitHub Actions** for multi-arch builds (amd64, arm64) + +### Parallel Processing Strategy (Dataset Operations) +| Operation | Approach | Rationale | +|-----------|----------|-----------| +| **SAQL Query Execution** | `asyncio.Semaphore` + `asyncio.gather` | I/O-bound, 10-20 concurrent requests | +| **Chunk Download** | Async HTTP with controlled concurrency | Network-bound, respect API limits | +| **CSV Merging** | `ProcessPoolExecutor` (multiprocessing) | CPU-bound, pandas concat is GIL-heavy | +| **Data Processing** | `ProcessPoolExecutor` for heavy transforms | CPU-bound, bypass GIL | +| **Progress Tracking** | Shared async queue + background task | Non-blocking UI updates | + +**Legacy Reference**: `_legacy/dataset_tasks/dataset_extract_MP.py` and `dataset_extract_MT.py` show the original multiprocessing approach. + +--- + +## 📦 Documentation Structure + +``` +docs/plans/ +├── README.md # This file - master index +├── phases/ +│ ├── phase-0-foundation-setup.md # Setup, deps, Docker, config +│ ├── phase-1-core-infrastructure.md # Session, Safety, App skeleton +│ ├── phase-2-navigation-browsers.md # Browsers, tables, detail panels +│ ├── phase-3-operations-background-tasks.md # TaskRunner, parallel ops +│ ├── phase-4-polish-dx.md # Themes, palette, tests, docs +│ └── phase-5-value-add-future.md # Parked features reference +└── architecture-decisions.md # Key decisions log (append-only) +``` + +--- + +## 🔧 Development Workflow + +### For Each Phase +1. **Read** the phase document completely +2. **Create** feature branch: `git checkout -b feature/phase-X-` +3. **Implement** following the explicit requirements +4. **Test** with `pytest` + manual TUI verification +5. **Document** any architecture decisions in `architecture-decisions.md` +6. **PR** with phase document as reference + +### Coding Agent Instructions +Each phase document contains: +- **Explicit requirements** (what to build) +- **Code patterns** (how to build it) +- **Acceptance criteria** (how to verify) +- **File paths** (where to put code) +- **Dependencies** (what to import) +- **Cross-platform notes** (OS-specific handling) + +--- + +## 🚀 Quick Start for Implementers + +```bash +# 1. Clone and setup +git clone +cd crma + +# 2. Install with interactive extras +uv sync --extra interactive --extra dev + +# 3. Or use Docker (when available) +docker compose up -d dev +docker compose exec dev bash + +# 4. Run TUI +tcrm # Interactive mode +tcrm --help # CLI mode +tcrm doctor # Diagnostics +``` + +--- + +## 📝 Architecture Decision Log + +See [architecture-decisions.md](architecture-decisions.md) for running log of key decisions. + +--- + +*Last updated: 2026-07-23* \ No newline at end of file diff --git a/docs/plans/phases/phase-0-foundation-setup.md b/docs/plans/phases/phase-0-foundation-setup.md new file mode 100644 index 0000000..ad922d9 --- /dev/null +++ b/docs/plans/phases/phase-0-foundation-setup.md @@ -0,0 +1,604 @@ +# Phase 0: Foundation Setup + +**Document**: `docs/plans/phases/phase-0-foundation-setup.md` +**Duration**: 2-3 days +**Branch**: `feature/phase-0-foundation-setup` (to be created when implementation begins) + +--- + +## 🎯 Objective + +Establish the project foundation for the Interactive TUI: +- Add `interactive` optional dependencies to `pyproject.toml` +- Create Docker support for consistent cross-platform environments +- Set up cross-platform configuration patterns +- Create the `tcrm_toolkit/interactive/` module structure +- Verify all dependencies work on Windows, Linux, macOS + +--- + +## 📋 Explicit Requirements + +### 1. Update `pyproject.toml` Dependencies + +**File**: `pyproject.toml` + +Add the following to `[project.optional-dependencies]`: + +```toml +[project.optional-dependencies] +interactive = [ + "textual>=0.52.0", + "textual-dev>=0.1.0", # Dev tools (CSS inspector, etc.) + "httpx>=0.27.0", # For ipapi.co calls (already in main deps) +] + +dev = [ + # ... existing dev deps ... + "pytest-textual>=0.1.0", # For TUI testing +] +``` + +**Verify**: Run `uv sync --extra interactive --extra dev` and confirm no conflicts. + +--- + +### 2. Create Docker Support + +#### 2.1 Dockerfile (Multi-stage) + +**File**: `Dockerfile` (repo root) + +```dockerfile +# ============================================================================= +# CRMA Toolkit - Interactive TUI Docker Image +# Multi-stage build for minimal production image +# ============================================================================= + +# ---- Build Stage ---- +FROM python:3.12-slim AS builder + +# Install system dependencies for building +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for fast dependency management +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Set working directory +WORKDIR /app + +# Copy dependency files first (for cache) +COPY pyproject.toml uv.lock* ./ + +# Install dependencies +RUN uv sync --extra interactive --extra dev --frozen + +# Copy source code +COPY . . + +# Install package in development mode +RUN uv pip install -e . + +# ---- Runtime Stage ---- +FROM python:3.12-slim AS runtime + +# Install runtime dependencies only +RUN apt-get update && apt-get install -y --no-install-recommends \ + # SF CLI dependencies + curl \ + gnupg \ + # Terminal support + procps \ + && rm -rf /var/lib/apt/lists/* + +# Install SF CLI (Salesforce CLI) +RUN curl -fsSL https://developer.salesforce.com/media/salesforce-cli/salesforce-cli-linux-x64.tar.xz | tar -xJ -C /usr/local/bin --strip-components=1 + +# Create non-root user +RUN useradd -m -s /bin/bash tcrm && \ + mkdir -p /home/tcrm/.config /home/tcrm/.local/share && \ + chown -R tcrm:tcrm /home/tcrm + +# Copy from builder +COPY --from=builder /app /app +COPY --from=builder /root/.local /home/tcrm/.local + +# Set environment +ENV PATH="/home/tcrm/.local/bin:${PATH}" +ENV HOME="/home/tcrm" +ENV USER="tcrm" + +# Switch to non-root user +USER tcrm +WORKDIR /home/tcrm + +# Default command +ENTRYPOINT ["tcrm"] +CMD ["--help"] +``` + +#### 2.2 Docker Compose for Development + +**File**: `docker-compose.yml` (repo root) + +```yaml +version: '3.8' + +services: + dev: + build: + context: . + dockerfile: Dockerfile + target: builder # Use builder stage for dev (has dev tools) + container_name: crma-dev + volumes: + - .:/app:cached + - ~/.ssh:/home/tcrm/.ssh:ro + - ~/.gitconfig:/home/tcrm/.gitconfig:ro + # Keyring/socket for auth (Linux) + - /run/user/${UID}/keyring:/run/user/${UID}/keyring:ro + environment: + - DISPLAY=${DISPLAY} + - WAYLAND_DISPLAY=${WAYLAND_DISPLAY} + - XDG_RUNTIME_DIR=/run/user/${UID} + - TERM=xterm-256color + # For GUI/terminal access + stdin_open: true + tty: true + # Network host for SF CLI localhost callbacks + network_mode: "host" + working_dir: /app + command: sleep infinity # Keep running for exec + + # Production-like runtime + prod: + build: + context: . + dockerfile: Dockerfile + target: runtime + container_name: crma-prod + volumes: + - tcrm-data:/home/tcrm/.local/share/tcrm + - tcrm-config:/home/tcrm/.config/tcrm + stdin_open: true + tty: true + network_mode: "host" + +volumes: + tcrm-data: + tcrm-config: +``` + +#### 2.3 Docker Helper Scripts + +**File**: `scripts/docker-dev.sh` + +```bash +#!/usr/bin/env bash +# Development helper: start dev container and attach + +set -euo pipefail + +# Build if needed +docker compose build dev + +# Start container +docker compose up -d dev + +# Attach with proper terminal +docker compose exec -it dev bash +``` + +**File**: `scripts/docker-run.sh` + +```bash +#!/usr/bin/env bash +# Run TUI in production container + +set -euo pipefail + +docker compose run --rm prod "$@" +``` + +Make executable: `chmod +x scripts/docker-*.sh` + +--- + +### 3. Cross-Platform Configuration Patterns + +#### 3.1 Platform Detection Utility + +**File**: `tcrm_toolkit/core/platform.py` (NEW) + +```python +"""Cross-platform utilities for OS detection and paths.""" + +import os +import sys +import platform +from pathlib import Path +from typing import Literal + +OSType = Literal["windows", "linux", "darwin"] + + +def get_os() -> OSType: + """Detect current operating system.""" + system = platform.system().lower() + if system == "windows": + return "windows" + elif system == "darwin": + return "darwin" + return "linux" + + +def get_config_dir(app_name: str = "tcrm") -> Path: + """Get platform-appropriate config directory.""" + os_type = get_os() + + if os_type == "windows": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + elif os_type == "darwin": + base = Path.home() / "Library" / "Application Support" + else: # Linux/Unix + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + + return base / app_name + + +def get_data_dir(app_name: str = "tcrm") -> Path: + """Get platform-appropriate data directory.""" + os_type = get_os() + + if os_type == "windows": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + elif os_type == "darwin": + base = Path.home() / "Library" / "Application Support" + else: # Linux/Unix + base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + + return base / app_name + + +def get_cache_dir(app_name: str = "tcrm") -> Path: + """Get platform-appropriate cache directory.""" + os_type = get_os() + + if os_type == "windows": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "Cache" + elif os_type == "darwin": + base = Path.home() / "Library" / "Caches" + else: # Linux/Unix + base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) + + return base / app_name + + +def is_windows() -> bool: + return get_os() == "windows" + + +def is_macos() -> bool: + return get_os() == "darwin" + + +def is_linux() -> bool: + return get_os() == "linux" + + +def get_terminal_size() -> tuple[int, int]: + """Get terminal size cross-platform.""" + try: + import shutil + return shutil.get_terminal_size() + except Exception: + return (80, 24) + + +def supports_true_color() -> bool: + """Check if terminal supports true color.""" + colorterm = os.environ.get("COLORTERM", "").lower() + return "truecolor" in colorterm or "24bit" in colorterm +``` + +#### 3.2 Update Settings for Cross-Platform Paths + +**File**: `tcrm_toolkit/core/config.py` (MODIFY) + +Add to `Settings` class: + +```python +# Cross-platform directories +config_dir: Path = Field(default_factory=lambda: get_config_dir()) +data_dir: Path = Field(default_factory=lambda: get_data_dir()) +cache_dir: Path = Field(default_factory=lambda: get_cache_dir()) + +# Ensure directories exist +def __post_init__(self): + for dir_path in [self.config_dir, self.data_dir, self.cache_dir]: + dir_path.mkdir(parents=True, exist_ok=True) +``` + +--- + +### 4. Create Interactive Module Structure + +**Directory**: `tcrm_toolkit/interactive/` + +Create the following structure: + +``` +tcrm_toolkit/interactive/ +├── __init__.py +├── app.py # Main Textual App (placeholder) +├── session.py # SessionManager (placeholder) +├── safety.py # SafetyMonitor (placeholder) +├── tasks.py # TaskRunner (placeholder) +├── config.py # TUI config (placeholder) +├── platform.py # Re-export from core.platform +├── screens/ +│ ├── __init__.py +│ ├── main_screen.py +│ ├── login_screen.py +│ ├── org_picker.py +│ └── safety_modal.py +├── widgets/ +│ ├── __init__.py +│ ├── data_table.py +│ ├── detail_panel.py +│ ├── progress_panel.py +│ ├── command_palette.py +│ ├── status_bar.py +│ └── task_history.py +├── operations/ +│ ├── __init__.py +│ ├── dataset_ops.py +│ ├── dashboard_ops.py +│ └── dataflow_ops.py +└── styles/ + ├── default.css + ├── dark.css + └── light.css +``` + +**File**: `tcrm_toolkit/interactive/__init__.py` + +```python +"""Interactive TUI module for CRMA Toolkit.""" + +from tcrm_toolkit.interactive.app import TCRMApp + +__all__ = ["TCRMApp"] +``` + +**File**: `tcrm_toolkit/interactive/platform.py` + +```python +"""Re-export platform utilities.""" +from tcrm_toolkit.core.platform import ( + get_os, + get_config_dir, + get_data_dir, + get_cache_dir, + is_windows, + is_macos, + is_linux, + get_terminal_size, + supports_true_color, + OSType, +) + +__all__ = [ + "get_os", + "get_config_dir", + "get_data_dir", + "get_cache_dir", + "is_windows", + "is_macos", + "is_linux", + "get_terminal_size", + "supports_true_color", + "OSType", +] +``` + +--- + +### 5. Verify Cross-Platform Compatibility + +Create a verification script: + +**File**: `scripts/verify-cross-platform.py` + +```python +#!/usr/bin/env python +"""Verify cross-platform compatibility of the codebase.""" + +import sys +import platform +import subprocess +from pathlib import Path + +def check_python_version(): + """Check Python version >= 3.11.""" + version = sys.version_info + assert version.major == 3 and version.minor >= 11, f"Python 3.11+ required, got {version}" + print(f"✅ Python {version.major}.{version.minor}.{version.micro}") + +def check_imports(): + """Verify all critical imports work.""" + imports = [ + ("textual", "textual"), + ("rich", "rich"), + ("httpx", "httpx"), + ("pydantic", "pydantic"), + ("pydantic_settings", "pydantic_settings"), + ("keyring", "keyring"), + ("cryptography", "cryptography"), + ("pandas", "pandas"), + ("structlog", "structlog"), + ("tenacity", "tenacity"), + ("typer", "typer"), + ] + + for name, module in imports: + try: + __import__(module) + print(f"✅ {name}") + except ImportError as e: + print(f"❌ {name}: {e}") + return False + return True + +def check_sf_cli(): + """Check SF CLI availability.""" + try: + result = subprocess.run(["sf", "--version"], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + print(f"✅ SF CLI: {result.stdout.strip()}") + else: + print(f"⚠️ SF CLI not found (install from https://developer.salesforce.com/tools/sfdxcli)") + except FileNotFoundError: + print(f"⚠️ SF CLI not found (install from https://developer.salesforce.com/tools/sfdxcli)") + except Exception as e: + print(f"⚠️ SF CLI check failed: {e}") + +def check_platform_utils(): + """Test platform utilities.""" + sys.path.insert(0, str(Path(__file__).parent.parent)) + from tcrm_toolkit.core.platform import get_os, get_config_dir, get_data_dir + + os_type = get_os() + print(f"✅ OS detected: {os_type}") + print(f"✅ Config dir: {get_config_dir()}") + print(f"✅ Data dir: {get_data_dir()}") + +def main(): + print(f"🔍 Cross-platform verification for {platform.system()} {platform.machine()}") + print("=" * 60) + + check_python_version() + print() + check_imports() + print() + check_sf_cli() + print() + check_platform_utils() + print() + print("=" * 60) + print("✅ All checks passed!") + +if __name__ == "__main__": + main() +``` + +--- + +### 6. GitHub Actions for Multi-Platform CI + +**File**: `.github/workflows/ci.yml` + +```yaml +name: CI + +on: + push: + branches: [main, refactor/**] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install dependencies + run: uv sync --extra interactive --extra dev + + - name: Run linting + run: uv run ruff check . + + - name: Run type checking + run: uv run mypy tcrm_toolkit + + - name: Run tests + run: uv run pytest -v --tb=short + + - name: Verify cross-platform + run: uv run python scripts/verify-cross-platform.py + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t crma-toolkit:test . + + - name: Test Docker image + run: docker run --rm crma-toolkit:test --help +``` + +--- + +## ✅ Acceptance Criteria + +| Check | Verification | +|-------|--------------| +| Dependencies install | `uv sync --extra interactive --extra dev` succeeds on all OSes | +| Docker builds | `docker build -t crma-toolkit .` succeeds | +| Docker runs | `docker run --rm crma-toolkit --help` shows help | +| Platform utils work | `python scripts/verify-cross-platform.py` passes | +| Module structure exists | `tcrm_toolkit/interactive/` with all subdirs | +| No hardcoded paths | `grep -r "C:\\\\|/home/" tcrm_toolkit/ --include="*.py" | grep -v test` returns empty | +| CI passes | GitHub Actions green on ubuntu, windows, macos | + +--- + +## 🔧 Coding Agent Instructions + +1. **Start with `pyproject.toml`** - Add dependencies first, verify install +2. **Create Dockerfile** - Test build locally before committing +3. **Create platform.py** - This is the foundation for all cross-platform code +4. **Create module structure** - Empty files with proper `__init__.py` exports +5. **Run verification script** - Must pass on your development machine +6. **Test Docker** - Both `docker-dev.sh` and `docker-run.sh` should work + +**Key Patterns to Follow**: +- Use `pathlib.Path` everywhere +- Import platform utilities from `tcrm_toolkit.core.platform` +- Use `asyncio.subprocess` not `subprocess` directly in async code +- Use `concurrent.futures.ProcessPoolExecutor` for CPU-bound work +- Never assume `/tmp` or `/home` exists on Windows + +--- + +## 📝 Architecture Decisions (Log in `architecture-decisions.md`) + +- [x] Decision: Use `uv` for dependency management in Docker +- [x] Decision: Multi-stage Docker build for minimal runtime image +- [x] Decision: Non-root user in Docker for security +- [x] Decision: Host network mode for SF CLI localhost callbacks +- [x] Decision: `pathlib.Path` + platform utils for all paths + +--- + +*End of Phase 0 Document* \ No newline at end of file diff --git a/docs/plans/phases/phase-1-core-infrastructure.md b/docs/plans/phases/phase-1-core-infrastructure.md new file mode 100644 index 0000000..3a576f1 --- /dev/null +++ b/docs/plans/phases/phase-1-core-infrastructure.md @@ -0,0 +1,1696 @@ +# Phase 1: Core Infrastructure + +**Document**: `docs/plans/phases/phase-1-core-infrastructure.md` +**Duration**: 1 week +**Branch**: `feature/phase-1-core-infrastructure` (to be created when implementation begins) +**Depends on**: Phase 0 complete +**Status**: ✅ Completed + +--- + +## 🎯 Objective + +Build the core infrastructure for the Interactive TUI: +- `SessionManager` — wraps `SFCLIAuthService` for persistent multi-org sessions +- `SafetyMonitor` — VPN/Proxy detection with hard-block on critical risk +- `TCRMApp` — Main Textual App with layout (sidebar, content, detail, status bar) +- Login screen with SF CLI web flow +- Org switcher (Ctrl+O) +- Status bar with safety indicator (🟢/🟡/🔴) + +--- + +## 📋 Explicit Requirements + +### 1. SessionManager + +**File**: `tcrm_toolkit/interactive/session.py` + +```python +"""Session management for Interactive TUI - wraps SFCLIAuthService.""" + +import asyncio +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Optional + +from tcrm_toolkit.core.auth import SFCLIAuthService, SFCLIAuthError +from tcrm_toolkit.core.client import SalesforceClient, create_client +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.crypto import CryptoManager, create_crypto_manager +from tcrm_toolkit.interactive.safety import SafetyMonitor, SafetyError + + +@dataclass +class OrgSession: + """Represents an authenticated org session.""" + alias: str + username: Optional[str] + instance_url: str + is_default: bool = False + + +class SessionManager: + """ + Manages authenticated sessions across the TUI lifecycle. + + Responsibilities: + - Multi-org credential management via SF CLI aliases + - Auto-refresh tokens before expiry + - Session persistence across TUI restarts + - Quick org switching (Ctrl+O) + - Safety gate: blocks client creation if VPN/Proxy detected + """ + + def __init__( + self, + settings: Settings | None = None, + crypto: CryptoManager | None = None, + safety_monitor: SafetyMonitor | None = None, + ): + self.settings = settings or get_settings() + self.crypto = crypto or create_crypto_manager() + self.safety = safety_monitor or SafetyMonitor(self.settings) + self._auth_service: SFCLIAuthService | None = None + self._current_alias: str = "default" + self._client: SalesforceClient | None = None + self._org_sessions: dict[str, OrgSession] = {} + + @property + def auth_service(self) -> SFCLIAuthService: + """Lazy-initialize SFCLIAuthService.""" + if self._auth_service is None: + self._auth_service = SFCLIAuthService( + settings=self.settings, + crypto_manager=self.crypto, + ) + return self._auth_service + + @property + def current_alias(self) -> str: + return self._current_alias + + @property + def current_org(self) -> OrgSession | None: + return self._org_sessions.get(self._current_alias) + + async def initialize(self) -> None: + """Initialize session - load orgs, check safety, auto-login if needed.""" + # Check connection safety first + safety_result = await self.safety.check_connection_safety() + if not safety_result.is_safe and self.settings.safety_block_on_critical: + raise SafetyError(f"Unsafe connection: {safety_result.details}") + + # Load available orgs from SF CLI + await self.refresh_org_list() + + # Try to get valid token for current/default org + try: + await self.ensure_valid_token() + except SFCLIAuthError: + # No valid token - will need login + pass + + async def refresh_org_list(self) -> list[OrgSession]: + """Refresh list of authorized orgs from SF CLI.""" + orgs = await self.auth_service.list_orgs() + self._org_sessions = {} + + for org in orgs: + alias = org.get("alias", "unknown") + username = org.get("username") + instance_url = org.get("instanceUrl", "") + connected = org.get("connectedStatus") == "Connected" + + if connected and username and instance_url: + self._org_sessions[alias] = OrgSession( + alias=alias, + username=username, + instance_url=instance_url.rstrip("/"), + is_default=(alias == "default"), + ) + + return list(self._org_sessions.values()) + + async def ensure_valid_token(self, alias: str | None = None) -> str: + """ + Get valid access token for alias, auto-refresh if needed. + + Args: + alias: Org alias (defaults to current) + + Returns: + Valid access token + + Raises: + SFCLIAuthError: If no valid token available + SafetyError: If connection safety check fails + """ + alias = alias or self._current_alias + + # Safety check before any API call + safety_result = await self.safety.check_connection_safety() + if not safety_result.is_safe and self.settings.safety_block_on_critical: + raise SafetyError(f"Unsafe connection: {safety_result.details}") + + token = await self.auth_service.get_access_token(alias=alias, auto_refresh=True) + return token + + async def get_client(self, alias: str | None = None) -> SalesforceClient: + """ + Get authenticated SalesforceClient for alias. + + Creates new client if alias changed or client expired. + """ + alias = alias or self._current_alias + + # Safety gate + safety_result = await self.safety.check_connection_safety() + if not safety_result.is_safe and self.settings.safety_block_on_critical: + raise SafetyError(f"Unsafe connection: {safety_result.details}") + + # Get token and instance URL + access_token = await self.ensure_valid_token(alias) + instance_url = await self.auth_service.get_instance_url(alias) + + # Create client + self._client = SalesforceClient( + access_token=access_token, + instance_url=instance_url, + settings=self.settings, + ) + + return self._client + + @asynccontextmanager + async def client_context(self, alias: str | None = None): + """Context manager for SalesforceClient with auto-cleanup.""" + client = await self.get_client(alias) + try: + yield client + finally: + await client.close() + self._client = None + + async def switch_org(self, alias: str) -> OrgSession: + """Switch to different org alias.""" + if alias not in self._org_sessions: + await self.refresh_org_list() + + if alias not in self._org_sessions: + raise SFCLIAuthError(f"Org alias '{alias}' not found. Run 'sf org list' first.") + + self._current_alias = alias + self._client = None # Force new client on next use + + # Verify token works for new org + await self.ensure_valid_token(alias) + + return self._org_sessions[alias] + + async def login(self, alias: str = "default", instance_url: str | None = None) -> str: + """Run SF CLI web login flow.""" + token = await self.auth_service.login(alias=alias, instance_url=instance_url) + await self.refresh_org_list() + self._current_alias = alias + return token + + async def logout(self, alias: str = "default") -> bool: + """Logout and remove stored auth for alias.""" + result = await self.auth_service.logout(alias) + await self.refresh_org_list() + + if alias == self._current_alias: + self._current_alias = "default" + self._client = None + + return result + + async def get_status(self, alias: str | None = None) -> dict: + """Get authentication status for alias.""" + alias = alias or self._current_alias + return await self.auth_service.status(alias) + + def list_orgs(self) -> list[OrgSession]: + """List all known org sessions.""" + return list(self._org_sessions.values()) + + async def close(self) -> None: + """Cleanup resources.""" + if self._client: + await self._client.close() + self._client = None + if self._auth_service: + await self._auth_service.close() + self._auth_service = None +``` + +--- + +### 2. SafetyMonitor (VPN/Proxy Detection) + +**File**: `tcrm_toolkit/interactive/safety.py` + +```python +"""Connection safety monitor - detects VPN/Proxy that trigger Salesforce blocks.""" + +import asyncio +import json +import os +import platform +import subprocess +import socket +from contextlib import suppress +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import Enum +from typing import Literal +from urllib.parse import urlparse + +import httpx +import structlog + +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.platform import is_windows, is_linux, is_macos + +logger = structlog.get_logger(__name__) + + +class RiskLevel(str, Enum): + SAFE = "safe" + WARNING = "warning" + CRITICAL = "critical" + + +class CheckName(str, Enum): + IP_REPUTATION = "ip_reputation" + VPN_INTERFACES = "vpn_interfaces" + SYSTEM_PROXY = "system_proxy" + DNS_LEAK = "dns_leak" + + +@dataclass +class CheckResult: + name: CheckName + passed: bool + details: str + remediation: str | None = None + risk_level: RiskLevel = RiskLevel.SAFE + + +@dataclass +class SafetyResult: + is_safe: bool + checks: dict[CheckName, CheckResult] = field(default_factory=dict) + risk_level: RiskLevel = RiskLevel.SAFE + details: str = "" + timestamp: datetime = field(default_factory=datetime.utcnow) + + def __post_init__(self): + # Determine overall risk level + if any(c.risk_level == RiskLevel.CRITICAL for c in self.checks.values()): + self.risk_level = RiskLevel.CRITICAL + self.is_safe = False + elif any(c.risk_level == RiskLevel.WARNING for c in self.checks.values()): + self.risk_level = RiskLevel.WARNING + self.is_safe = True # Warning doesn't block + else: + self.risk_level = RiskLevel.SAFE + self.is_safe = True + + # Build details string + failed = [c for c in self.checks.values() if not c.passed] + if failed: + self.details = "; ".join(f"{c.name.value}: {c.details}" for c in failed) + + +class SafetyError(Exception): + """Raised when safety check fails and blocking is enabled.""" + pass + + +class SafetyMonitor: + """ + Monitors connection for VPN/Proxy that could trigger Salesforce blocks. + + Salesforce now IMMEDIATELY disables users detected on VPN/Proxy. + This monitor runs on startup and periodically in background. + + Detection Methods: + 1. IP Reputation (ipapi.co) - VPN, Proxy, Tor, Hosting detection + 2. VPN Interfaces - Local network interface scanning + 3. System Proxy - OS proxy settings detection + 4. DNS Leak - DNS resolution consistency check + """ + + # Known VPN interface prefixes + VPN_INTERFACE_PREFIXES = ( + "tun", "tap", "wg", "vpn", "wireguard", + "ppp", "ipsec", "sslvpn", "openvpn", + "nordlynx", "proton", "mullvad", "expressvpn", + ) + + # IP reputation service + IP_API_URL = "https://ipapi.co/json/" + IP_API_TIMEOUT = 10.0 + + def __init__(self, settings: Settings | None = None): + self.settings = settings or get_settings() + self._cache: SafetyResult | None = None + self._cache_expires: datetime | None = None + self._monitor_task: asyncio.Task | None = None + self._http_client: httpx.AsyncClient | None = None + + @property + def http_client(self) -> httpx.AsyncClient: + if self._http_client is None: + self._http_client = httpx.AsyncClient( + timeout=httpx.Timeout(self.IP_API_TIMEOUT), + follow_redirects=True, + ) + return self._http_client + + async def close(self) -> None: + if self._http_client: + await self._http_client.aclose() + self._http_client = None + if self._monitor_task: + self._monitor_task.cancel() + with suppress(asyncio.CancelledError): + await self._monitor_task + + def _is_cache_valid(self) -> bool: + if not self._cache or not self._cache_expires: + return False + return datetime.utcnow() < self._cache_expires + + async def check_connection_safety(self, force: bool = False) -> SafetyResult: + """ + Run all safety checks and return combined result. + + Args: + force: Skip cache and run fresh checks + + Returns: + SafetyResult with overall safety status + """ + if not force and self._is_cache_valid(): + return self._cache + + logger.info("running_safety_checks") + + # Run all checks in parallel + results = await asyncio.gather( + self._check_ip_reputation(), + self._check_vpn_interfaces(), + self._check_system_proxy(), + self._check_dns_leak(), + return_exceptions=True, + ) + + checks = {} + for i, result in enumerate(results): + check_name = list(CheckName)[i] + if isinstance(result, Exception): + logger.error("safety_check_failed", check=check_name.value, error=str(result)) + checks[check_name] = CheckResult( + name=check_name, + passed=True, # Fail open for check errors + details=f"Check failed: {result}", + risk_level=RiskLevel.SAFE, + ) + else: + checks[check_name] = result + + safety_result = SafetyResult(checks=checks) + + # Cache result + self._cache = safety_result + self._cache_expires = datetime.utcnow() + timedelta( + seconds=self.settings.safety_check_interval + ) + + logger.info( + "safety_check_complete", + is_safe=safety_result.is_safe, + risk_level=safety_result.risk_level.value, + details=safety_result.details, + ) + + return safety_result + + async def _check_ip_reputation(self) -> CheckResult: + """Check IP reputation via ipapi.co.""" + # Check allowlist first + if self.settings.safety_allowlist_ips: + try: + current_ip = await self._get_current_ip() + if current_ip in self.settings.safety_allowlist_ips: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, + details=f"IP {current_ip} in allowlist", + ) + except Exception: + pass + + try: + response = await self.http_client.get(self.IP_API_URL) + response.raise_for_status() + data = response.json() + + # Check security fields + security = data.get("security", {}) + is_vpn = security.get("vpn", False) + is_proxy = security.get("proxy", False) + is_tor = security.get("tor", False) + is_hosting = security.get("hosting", False) + is_relay = security.get("relay", False) + + ip = data.get("ip", "unknown") + country = data.get("country_name", "unknown") + + if is_vpn or is_proxy or is_tor: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=False, + details=f"IP {ip} ({country}): VPN={is_vpn}, Proxy={is_proxy}, Tor={is_tor}, Hosting={is_hosting}", + remediation="Disconnect VPN/Proxy and retry. Use allowlist for trusted IPs.", + risk_level=RiskLevel.CRITICAL, + ) + + if is_hosting or is_relay: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=False, + details=f"IP {ip} ({country}): Hosting={is_hosting}, Relay={is_relay} (may trigger blocks)", + remediation="Consider using residential IP. Add to allowlist if trusted.", + risk_level=RiskLevel.WARNING, + ) + + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, + details=f"IP {ip} ({country}): Clean", + ) + + except httpx.TimeoutException: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, # Fail open on timeout + details="IP reputation check timed out", + risk_level=RiskLevel.SAFE, + ) + except Exception as e: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, # Fail open on error + details=f"IP reputation check failed: {e}", + risk_level=RiskLevel.SAFE, + ) + + async def _get_current_ip(self) -> str: + """Get current public IP.""" + try: + response = await self.http_client.get("https://api.ipify.org?format=json") + response.raise_for_status() + return response.json().get("ip", "unknown") + except Exception: + return "unknown" + + async def _check_vpn_interfaces(self) -> CheckResult: + """Scan for VPN network interfaces.""" + vpn_interfaces = [] + + try: + if is_windows(): + # Windows: use GetAdaptersAddresses via PowerShell + cmd = [ + "powershell", "-Command", + "Get-NetAdapter | Where-Object {$_.InterfaceDescription -match 'VPN|TAP|TUN|WireGuard|OpenVPN|NordLynx|Proton|Mullvad|ExpressVPN'} | Select-Object Name, InterfaceDescription | ConvertTo-Json" + ] + result = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await result.communicate() + if stdout: + adapters = json.loads(stdout.decode()) + if not isinstance(adapters, list): + adapters = [adapters] + for adapter in adapters: + name = adapter.get("Name", "") + desc = adapter.get("InterfaceDescription", "") + vpn_interfaces.append(f"{name} ({desc})") + else: + # Linux/macOS: scan /sys/class/net + net_path = Path("/sys/class/net") + if net_path.exists(): + for iface in net_path.iterdir(): + iface_name = iface.name.lower() + if any(iface_name.startswith(prefix) for prefix in self.VPN_INTERFACE_PREFIXES): + # Get interface details + try: + operstate = (iface / "operstate").read_text().strip() + if operstate == "up": + vpn_interfaces.append(f"{iface.name} (up)") + except Exception: + vpn_interfaces.append(iface.name) + except Exception as e: + logger.debug("vpn_interface_scan_failed", error=str(e)) + + if vpn_interfaces: + return CheckResult( + name=CheckName.VPN_INTERFACES, + passed=False, + details=f"VPN interfaces detected: {', '.join(vpn_interfaces)}", + remediation="Disconnect VPN and retry. Check 'ip link' or 'Get-NetAdapter'.", + risk_level=RiskLevel.CRITICAL, + ) + + return CheckResult( + name=CheckName.VPN_INTERFACES, + passed=True, + details="No VPN interfaces detected", + ) + + async def _check_system_proxy(self) -> CheckResult: + """Check system proxy settings.""" + proxy_vars = [ + "http_proxy", "https_proxy", "ftp_proxy", "all_proxy", + "HTTP_PROXY", "HTTPS_PROXY", "FTP_PROXY", "ALL_PROXY", + ] + + set_proxies = {var: os.environ.get(var) for var in proxy_vars if os.environ.get(var)} + + # Also check OS-specific proxy settings + os_proxy = "" + if is_windows(): + try: + cmd = ["powershell", "-Command", "Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings' | Select-Object ProxyEnable, ProxyServer | ConvertTo-Json"] + result = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE) + stdout, _ = await result.communicate() + if stdout: + data = json.loads(stdout.decode()) + if data.get("ProxyEnable") == 1: + os_proxy = f"Windows Proxy: {data.get('ProxyServer', 'enabled')}" + except Exception: + pass + elif is_macos(): + try: + result = await asyncio.create_subprocess_exec( + "networksetup", "-getwebproxy", "Wi-Fi", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await result.communicate() + output = stdout.decode() + if "Enabled: Yes" in output: + os_proxy = f"macOS Proxy: {output}" + except Exception: + pass + elif is_linux(): + # Check GNOME/KDE proxy settings + for cmd in [ + ["gsettings", "get", "org.gnome.system.proxy", "mode"], + ["kreadconfig5", "--group", "Proxy Settings", "--key", "ProxyType"], + ]: + try: + result = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE) + stdout, _ = await result.communicate() + if stdout and b"none" not in stdout.lower() and b"0" not in stdout: + os_proxy = f"Linux Proxy: {stdout.decode().strip()}" + break + except Exception: + pass + + all_proxies = {**set_proxies} + if os_proxy: + all_proxies["os_proxy"] = os_proxy + + if all_proxies: + details = "; ".join(f"{k}={v}" for k, v in all_proxies.items()) + return CheckResult( + name=CheckName.SYSTEM_PROXY, + passed=False, + details=f"System proxy configured: {details}", + remediation="Disable system proxy or use allowlist. Check environment variables and OS network settings.", + risk_level=RiskLevel.WARNING, # Proxy env vars don't always mean active proxy + ) + + return CheckResult( + name=CheckName.SYSTEM_PROXY, + passed=True, + details="No system proxy detected", + ) + + async def _check_dns_leak(self) -> CheckResult: + """Check for DNS leaks by comparing system DNS vs known clean DNS.""" + try: + # Resolve via system DNS + system_ips = await asyncio.get_event_loop().getaddrinfo( + "whoami.akamai.net", None, family=socket.AF_INET + ) + system_ip = system_ips[0][4][0] if system_ips else None + + # Resolve via clean DNS (1.1.1.1) using httpx + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get("https://1.1.1.1/dns-query?name=whoami.akamai.net&type=A", + headers={"Accept": "application/dns-json"}) + response.raise_for_status() + data = response.json() + clean_ip = data.get("Answer", [{}])[0].get("data") if data.get("Answer") else None + + if system_ip and clean_ip and system_ip != clean_ip: + return CheckResult( + name=CheckName.DNS_LEAK, + passed=False, + details=f"DNS leak detected: System={system_ip}, Clean={clean_ip}", + remediation="Check DNS settings. VPN may be leaking DNS. Use VPN with DNS leak protection.", + risk_level=RiskLevel.WARNING, + ) + + return CheckResult( + name=CheckName.DNS_LEAK, + passed=True, + details=f"DNS consistent: {system_ip or clean_ip}", + ) + except Exception as e: + logger.debug("dns_leak_check_failed", error=str(e)) + return CheckResult( + name=CheckName.DNS_LEAK, + passed=True, # Fail open + details=f"DNS leak check failed: {e}", + risk_level=RiskLevel.SAFE, + ) + + async def start_monitoring(self, callback=None, interval: int | None = None) -> None: + """Start background monitoring task.""" + interval = interval or self.settings.safety_check_interval + + async def monitor_loop(): + while True: + try: + result = await self.check_connection_safety(force=True) + if callback: + await callback(result) + except asyncio.CancelledError: + break + except Exception as e: + logger.error("safety_monitor_error", error=str(e)) + + await asyncio.sleep(interval) + + self._monitor_task = asyncio.create_task(monitor_loop()) + logger.info("safety_monitor_started", interval=interval) + + def stop_monitoring(self) -> None: + """Stop background monitoring.""" + if self._monitor_task: + self._monitor_task.cancel() + self._monitor_task = None + logger.info("safety_monitor_stopped") +``` + +--- + +### 3. Main Textual App (TCRMApp) + +**File**: `tcrm_toolkit/interactive/app.py` + +```python +"""Main Textual App for CRMA Toolkit Interactive TUI.""" + +import asyncio +from pathlib import Path +from typing import Optional + +from textual import on, work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Container, Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Footer, Header, Static + +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.interactive.session import SessionManager +from tcrm_toolkit.interactive.safety import SafetyMonitor, SafetyResult, RiskLevel +from tcrm_toolkit.interactive.screens.login_screen import LoginScreen +from tcrm_toolkit.interactive.screens.main_screen import MainScreen +from tcrm_toolkit.interactive.screens.org_picker import OrgPickerScreen +from tcrm_toolkit.interactive.screens.safety_modal import SafetyModalScreen +from tcrm_toolkit.interactive.widgets.status_bar import StatusBar + + +class TCRMApp(App): + """ + Main Interactive TUI Application for CRMA Toolkit. + + Features: + - Persistent session with SF CLI auth + - Multi-org support with quick switching + - VPN/Proxy safety monitoring with hard-block + - Background task execution with progress + - Command palette (Ctrl+P) + - Keyboard-driven navigation + """ + + CSS_PATH = "styles/dark.css" + + BINDINGS = [ + Binding("ctrl+q", "quit", "Quit", show=True), + Binding("ctrl+p", "command_palette", "Command Palette", show=True), + Binding("ctrl+o", "org_picker", "Switch Org", show=True), + Binding("ctrl+r", "refresh", "Refresh", show=True), + Binding("f1", "help", "Help", show=False), + Binding("escape", "escape", "Back/Cancel", show=False), + ] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.settings = get_settings() + self.crypto = create_crypto_manager() + self.safety = SafetyMonitor(self.settings) + self.session = SessionManager( + settings=self.settings, + crypto=self.crypto, + safety_monitor=self.safety, + ) + self._main_screen: MainScreen | None = None + self._safety_check_interval = self.settings.safety_check_interval + + def compose(self) -> ComposeResult: + """Compose the app layout.""" + yield Header(show_clock=True) + yield Container(id="main-container") + yield StatusBar(id="status-bar") + yield Footer() + + async def on_mount(self) -> None: + """Initialize app on mount.""" + # Set up safety monitoring callback + self.safety.start_monitoring(callback=self._on_safety_update) + + # Initialize session + try: + await self.session.initialize() + except Exception as e: + self.notify(f"Session init failed: {e}", severity="error") + + # Check safety - show modal if critical + safety_result = await self.safety.check_connection_safety() + if safety_result.risk_level == RiskLevel.CRITICAL and self.settings.safety_block_on_critical: + self.push_screen(SafetyModalScreen(safety_result), self._on_safety_modal_dismiss) + else: + await self._show_main_screen() + + async def _on_safety_update(self, result: SafetyResult) -> None: + """Handle safety monitor updates.""" + # Update status bar + status_bar = self.query_one("#status-bar", StatusBar) + status_bar.update_safety(result) + + # If critical risk appeared during session, show modal + if result.risk_level == RiskLevel.CRITICAL and self.settings.safety_block_on_critical: + if not self.screen_stack or not isinstance(self.screen_stack[-1], SafetyModalScreen): + self.push_screen(SafetyModalScreen(result), self._on_safety_modal_dismiss) + + def _on_safety_modal_dismiss(self, action: str) -> None: + """Handle safety modal dismissal.""" + if action == "retry": + # Re-check safety + self.run_worker(self._recheck_safety_and_continue()) + elif action == "continue": + # User acknowledged risk - allow but warn + self.notify("⚠️ Proceeding at your own risk!", severity="warning", timeout=10) + self.run_worker(self._show_main_screen()) + elif action == "quit": + self.exit() + + async def _recheck_safety_and_continue(self) -> None: + """Re-check safety after user action.""" + result = await self.safety.check_connection_safety(force=True) + if result.risk_level == RiskLevel.CRITICAL: + self.push_screen(SafetyModalScreen(result), self._on_safety_modal_dismiss) + else: + await self._show_main_screen() + + async def _show_main_screen(self) -> None: + """Show main screen after auth/safety checks.""" + # Pop any modal screens + while len(self.screen_stack) > 1: + self.pop_screen() + + # Check if we need login + if not self.session.current_org: + self.push_screen(LoginScreen(self.session), self._on_login_complete) + else: + await self._mount_main_screen() + + def _on_login_complete(self, success: bool) -> None: + """Handle login screen completion.""" + if success: + self.run_worker(self._mount_main_screen()) + else: + self.notify("Login failed", severity="error") + self.push_screen(LoginScreen(self.session), self._on_login_complete) + + async def _mount_main_screen(self) -> None: + """Mount the main screen.""" + if self._main_screen is None: + self._main_screen = MainScreen(self.session, self.safety) + + container = self.query_one("#main-container", Container) + await container.mount(self._main_screen) + self._main_screen.focus() + + # ========================================================================= + # Actions + # ========================================================================= + + async def action_command_palette(self) -> None: + """Show command palette.""" + if self._main_screen: + await self._main_screen.action_command_palette() + + async def action_org_picker(self) -> None: + """Show org picker.""" + orgs = self.session.list_orgs() + if not orgs: + self.notify("No orgs configured. Run 'sf org login web' first.", severity="warning") + return + + def on_org_selected(alias: str) -> None: + self.run_worker(self._switch_org(alias)) + + self.push_screen(OrgPickerScreen(orgs, self.session.current_alias), on_org_selected) + + async def _switch_org(self, alias: str) -> None: + """Switch to selected org.""" + try: + await self.session.switch_org(alias) + self.notify(f"Switched to org: {alias}", severity="information") + if self._main_screen: + await self._main_screen.refresh_data() + # Update status bar + status_bar = self.query_one("#status-bar", StatusBar) + status_bar.update_org(self.session.current_org) + except Exception as e: + self.notify(f"Failed to switch org: {e}", severity="error") + + async def action_refresh(self) -> None: + """Refresh current view.""" + if self._main_screen: + await self._main_screen.refresh_data() + self.notify("Refreshed", severity="information", timeout=2) + + async def action_escape(self) -> None: + """Handle escape key - context dependent.""" + if self._main_screen: + await self._main_screen.action_escape() + + async def on_unmount(self) -> None: + """Cleanup on app exit.""" + self.safety.stop_monitoring() + await self.session.close() + await self.safety.close() + + +# Entry point for `tcrm` command +def main() -> None: + """Main entry point for interactive TUI.""" + app = TCRMApp() + app.run() + + +if __name__ == "__main__": + main() +``` + +--- + +### 4. Login Screen + +**File**: `tcrm_toolkit/interactive/screens/login_screen.py` + +```python +"""Login screen for initial authentication.""" + +from textual import on, work +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label, Static +from textual.widgets import Input + +from tcrm_toolkit.interactive.session import SessionManager + + +class LoginScreen(ModalScreen[bool]): + """Modal screen for SF CLI web login.""" + + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ] + + def __init__(self, session: SessionManager): + super().__init__() + self.session = session + self._alias = "default" + self._instance_url = "" + + def compose(self) -> ComposeResult: + yield Container( + Vertical( + Static("🔐 Salesforce Authentication", id="login-title"), + Static( + "This will open a browser window for SF CLI web authentication.\n" + "Make sure SF CLI is installed: https://developer.salesforce.com/tools/sfdxcli", + id="login-info" + ), + Input(placeholder="Org alias (default)", id="alias-input", value="default"), + Input(placeholder="Custom instance URL (optional)", id="instance-input"), + Static("", id="login-status"), + Button("Login with SF CLI", id="login-btn", variant="primary"), + Button("Cancel", id="cancel-btn", variant="default"), + id="login-form" + ), + id="login-container" + ) + + @on(Button.Pressed, "#login-btn") + async def on_login_pressed(self) -> None: + """Handle login button press.""" + alias_input = self.query_one("#alias-input", Input) + instance_input = self.query_one("#instance-input", Input) + status = self.query_one("#login-status", Static) + login_btn = self.query_one("#login-btn", Button) + + self._alias = alias_input.value or "default" + self._instance_url = instance_input.value or None + + login_btn.disabled = True + status.update("🔄 Opening browser for authentication...") + + try: + await self._run_login() + self.dismiss(True) + except Exception as e: + status.update(f"❌ Login failed: {e}") + login_btn.disabled = False + + @work(exclusive=True) + async def _run_login(self) -> None: + """Run SF CLI login in background.""" + status = self.query_one("#login-status", Static) + + token = await self.session.login( + alias=self._alias, + instance_url=self._instance_url, + ) + + status.update(f"✅ Authenticated as {self.session.current_org.username}") + + @on(Button.Pressed, "#cancel-btn") + def on_cancel_pressed(self) -> None: + self.dismiss(False) + + def action_cancel(self) -> None: + self.dismiss(False) +``` + +--- + +### 5. Org Picker Screen + +**File**: `tcrm_toolkit/interactive/screens/org_picker.py` + +```python +"""Org picker screen for quick org switching.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label, ListItem, ListView, Static + +from tcrm_toolkit.interactive.session import OrgSession + + +class OrgPickerScreen(ModalScreen[str]): + """Modal screen for picking org alias.""" + + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ("enter", "select", "Select"), + ] + + def __init__(self, orgs: list[OrgSession], current_alias: str): + super().__init__() + self.orgs = orgs + self.current_alias = current_alias + + def compose(self) -> ComposeResult: + yield Container( + Vertical( + Static("🔐 Switch Organization", id="picker-title"), + ListView( + *[ + ListItem( + Label( + f"{'● ' if org.alias == self.current_alias else ' '}" + f"{org.alias} — {org.username} ({org.instance_url})" + ), + id=f"org-{org.alias}", + ) + for org in self.orgs + ], + id="org-list" + ), + Button("Cancel", id="cancel-btn"), + id="picker-container" + ), + id="picker-dialog" + ) + + @on(ListView.Selected, "#org-list") + def on_org_selected(self, event: ListView.Selected) -> None: + item = event.item + if item.id and item.id.startswith("org-"): + alias = item.id[4:] + self.dismiss(alias) + + @on(Button.Pressed, "#cancel-btn") + def on_cancel(self) -> None: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_select(self) -> None: + list_view = self.query_one("#org-list", ListView) + if list_view.highlighted_child: + item = list_view.highlighted_child + if item.id and item.id.startswith("org-"): + alias = item.id[4:] + self.dismiss(alias) +``` + +--- + +### 6. Safety Modal Screen + +**File**: `tcrm_toolkit/interactive/screens/safety_modal.py` + +```python +"""Safety modal for critical VPN/Proxy detection.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Checkbox, Label, Static + +from tcrm_toolkit.interactive.safety import SafetyResult, RiskLevel + + +class SafetyModalScreen(ModalScreen[str]): + """Modal dialog for critical safety alerts.""" + + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ] + + def __init__(self, safety_result: SafetyResult): + super().__init__() + self.safety_result = safety_result + self._dont_show_again = False + + def compose(self) -> ComposeResult: + # Build details text + details_lines = [] + for check in self.safety_result.checks.values(): + if not check.passed: + icon = "🔴" if check.risk_level == RiskLevel.CRITICAL else "🟡" + details_lines.append(f"{icon} {check.name.value}: {check.details}") + if check.remediation: + details_lines.append(f" → {check.remediation}") + + details_text = "\n".join(details_lines) if details_lines else "Unknown risk detected" + + yield Container( + Vertical( + Static("⚠️ CONNECTION SAFETY ALERT", id="safety-title"), + Static( + "Salesforce detects VPN/Proxy connections and will IMMEDIATELY disable your user.\n" + "Continuing risks permanent org lockout.", + id="safety-warning" + ), + Static(details_text, id="safety-details"), + Checkbox("Don't show again this session", id="dont-show-checkbox"), + Container( + Button("Disconnect VPN & Retry", id="retry-btn", variant="primary"), + Button("I Understand Risks - Continue", id="continue-btn", variant="warning"), + Button("Quit", id="quit-btn", variant="error"), + id="safety-buttons" + ), + id="safety-container" + ), + id="safety-dialog" + ) + + @on(Button.Pressed, "#retry-btn") + def on_retry(self) -> None: + self.dismiss("retry") + + @on(Button.Pressed, "#continue-btn") + def on_continue(self) -> None: + checkbox = self.query_one("#dont-show-checkbox", Checkbox) + self._dont_show_again = checkbox.value + self.dismiss("continue") + + @on(Button.Pressed, "#quit-btn") + def on_quit(self) -> None: + self.dismiss("quit") + + def action_cancel(self) -> None: + self.dismiss("quit") +``` + +--- + +### 7. Status Bar Widget + +**File**: `tcrm_toolkit/interactive/widgets/status_bar.py` + +```python +"""Status bar widget for bottom of TUI.""" + +from textual.widgets import Static +from textual.containers import Horizontal + +from tcrm_toolkit.interactive.safety import SafetyResult, RiskLevel +from tcrm_toolkit.interactive.session import OrgSession + + +class StatusBar(Static): + """Bottom status bar showing org, safety, API usage, background tasks.""" + + def __init__(self): + super().__init__("", id="status-bar") + self._org: OrgSession | None = None + self._safety: SafetyResult | None = None + self._api_usage = "0/15,000" + self._bg_tasks = 0 + + def update_org(self, org: OrgSession | None) -> None: + self._org = org + self._render() + + def update_safety(self, safety: SafetyResult) -> None: + self._safety = safety + self._render() + + def update_api_usage(self, used: int, limit: int) -> None: + self._api_usage = f"{used:,}/{limit:,}" + self._render() + + def update_bg_tasks(self, count: int) -> None: + self._bg_tasks = count + self._render() + + def _render(self) -> None: + parts = [] + + # Org info + if self._org: + parts.append(f"Org: {self._org.alias} ({self._org.username})") + else: + parts.append("Org: Not connected") + + # Safety indicator + if self._safety: + if self._safety.risk_level == RiskLevel.CRITICAL: + parts.append("🔴 UNSAFE") + elif self._safety.risk_level == RiskLevel.WARNING: + parts.append("🟡 WARNING") + else: + parts.append("🟢 SAFE") + else: + parts.append("🟢 SAFE") + + # API usage + parts.append(f"API: {self._api_usage}") + + # Background tasks + if self._bg_tasks > 0: + parts.append(f"BG: {self._bg_tasks} running") + + self.update(" • ".join(parts)) +``` + +--- + +### 8. Main Screen Skeleton + +**File**: `tcrm_toolkit/interactive/screens/main_screen.py` + +```python +"""Main screen with sidebar navigation and content area.""" + +from textual import on, work +from textual.app import ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, DataTable, Label, ListItem, ListView, Static, TabbedContent, TabPane + +from tcrm_toolkit.interactive.session import SessionManager +from tcrm_toolkit.interactive.safety import SafetyMonitor +from tcrm_toolkit.interactive.widgets.detail_panel import DetailPanel + + +class MainScreen(Screen): + """Main screen with navigation sidebar and content area.""" + + BINDINGS = [ + ("ctrl+p", "command_palette", "Command Palette"), + ("ctrl+o", "org_picker", "Switch Org"), + ("ctrl+r", "refresh", "Refresh"), + ("escape", "escape", "Back"), + ] + + def __init__(self, session: SessionManager, safety: SafetyMonitor): + super().__init__() + self.session = session + self.safety = safety + self._current_view = "datasets" + + def compose(self) -> ComposeResult: + yield Horizontal( + # Sidebar Navigation + Vertical( + Static("📊 TCRM Toolkit", id="sidebar-title"), + ListView( + ListItem(Label("📊 Datasets"), id="nav-datasets"), + ListItem(Label("📈 Dashboards"), id="nav-dashboards"), + ListItem(Label("🔄 Dataflows"), id="nav-dataflows"), + ListItem(Label("📋 Jobs"), id="nav-jobs"), + ListItem(Label("🔐 Orgs"), id="nav-orgs"), + ListItem(Label("⚙️ Config"), id="nav-config"), + id="nav-list" + ), + Static("[dim]Ctrl+P: Commands Ctrl+O: Orgs Ctrl+R: Refresh[/dim]", id="sidebar-hints"), + id="sidebar" + ), + # Main Content Area + Vertical( + Static("Select a navigation item", id="content-title"), + Container(id="content-area"), + id="content" + ), + # Detail Panel (right side) + DetailPanel(id="detail-panel"), + id="main-layout" + ) + + async def on_mount(self) -> None: + """Initialize main screen.""" + # Select first nav item + nav_list = self.query_one("#nav-list", ListView) + nav_list.index = 0 + await self._switch_view("datasets") + + @on(ListView.Selected, "#nav-list") + async def on_nav_selected(self, event: ListView.Selected) -> None: + item = event.item + if item.id and item.id.startswith("nav-"): + view = item.id[4:] + await self._switch_view(view) + + async def _switch_view(self, view: str) -> None: + """Switch to different view.""" + self._current_view = view + + # Update title + titles = { + "datasets": "📊 Datasets", + "dashboards": "📈 Dashboards", + "dataflows": "🔄 Dataflows", + "jobs": "📋 Dataflow Jobs", + "orgs": "🔐 Organizations", + "config": "⚙️ Configuration", + } + self.query_one("#content-title", Static).update(titles.get(view, view)) + + # Load view content + await self._load_view(view) + + async def _load_view(self, view: str) -> None: + """Load content for view.""" + container = self.query_one("#content-area", Container) + await container.remove_children() + + if view == "datasets": + await self._load_datasets_view(container) + elif view == "dashboards": + await self._load_dashboards_view(container) + elif view == "dataflows": + await self._load_dataflows_view(container) + elif view == "jobs": + await self._load_jobs_view(container) + elif view == "orgs": + await self._load_orgs_view(container) + elif view == "config": + await self._load_config_view(container) + + async def _load_datasets_view(self, container: Container) -> None: + """Load datasets table.""" + table = DataTable(id="datasets-table", cursor_type="row") + table.add_columns("#", "ID", "Name", "Label", "Rows", "Status") + table.zebra_stripes = True + await container.mount(table) + + # Load data in background + self.run_worker(self._populate_datasets(table), exclusive=True) + + @work(exclusive=True) + async def _populate_datasets(self, table: DataTable) -> None: + """Populate datasets table.""" + try: + async with self.session.client_context() as client: + from tcrm_toolkit.core.services.dataset_service import DatasetService + service = DatasetService(client, self.session.settings) + datasets = await service.list_datasets(page_size=100) + + for i, ds in enumerate(datasets, 1): + rows = f"{ds.row_count:,}" if ds.row_count else "N/A" + table.add_row(str(i), ds.id, ds.name, ds.label, rows, ds.status) + except Exception as e: + self.notify(f"Failed to load datasets: {e}", severity="error") + + async def _load_dashboards_view(self, container: Container) -> None: + """Load dashboards table.""" + table = DataTable(id="dashboards-table", cursor_type="row") + table.add_columns("#", "ID", "Name", "Label", "Folder") + table.zebra_stripes = True + await container.mount(table) + + self.run_worker(self._populate_dashboards(table), exclusive=True) + + @work(exclusive=True) + async def _populate_dashboards(self, table: DataTable) -> None: + try: + async with self.session.client_context() as client: + from tcrm_toolkit.core.services.dashboard_service import DashboardService + service = DashboardService(client, self.session.settings) + dashboards = await service.list_dashboards() + + for i, db in enumerate(dashboards, 1): + folder = db.folder_name or "N/A" + table.add_row(str(i), db.id, db.name, db.label, folder) + except Exception as e: + self.notify(f"Failed to load dashboards: {e}", severity="error") + + async def _load_dataflows_view(self, container: Container) -> None: + """Load dataflows table.""" + table = DataTable(id="dataflows-table", cursor_type="row") + table.add_columns("#", "ID", "Name", "Label", "Status") + table.zebra_stripes = True + await container.mount(table) + + self.run_worker(self._populate_dataflows(table), exclusive=True) + + @work(exclusive=True) + async def _populate_dataflows(self, table: DataTable) -> None: + try: + async with self.session.client_context() as client: + from tcrm_toolkit.core.services.dataflow_service import DataflowService + service = DataflowService(client, self.session.settings) + dataflows = await service.list_dataflows() + + for i, df in enumerate(dataflows, 1): + table.add_row(str(i), df.id, df.name, df.label, df.status) + except Exception as e: + self.notify(f"Failed to load dataflows: {e}", severity="error") + + async def _load_jobs_view(self, container: Container) -> None: + """Load dataflow jobs table.""" + table = DataTable(id="jobs-table", cursor_type="row") + table.add_columns("#", "ID", "Dataflow", "Command", "Status", "Start Time", "End Time") + table.zebra_stripes = True + await container.mount(table) + + self.run_worker(self._populate_jobs(table), exclusive=True) + + @work(exclusive=True) + async def _populate_jobs(self, table: DataTable) -> None: + try: + async with self.session.client_context() as client: + from tcrm_toolkit.core.services.dataflow_service import DataflowService + service = DataflowService(client, self.session.settings) + jobs = await service.list_dataflow_jobs() + + for i, job in enumerate(jobs, 1): + start = job.start_time.strftime("%Y-%m-%d %H:%M") if job.start_time else "N/A" + end = job.end_time.strftime("%Y-%m-%d %H:%M") if job.end_time else "N/A" + table.add_row(str(i), job.id, job.dataflow_name, job.command, job.status, start, end) + except Exception as e: + self.notify(f"Failed to load jobs: {e}", severity="error") + + async def _load_orgs_view(self, container: Container) -> None: + """Load orgs list.""" + orgs = self.session.list_orgs() + + table = DataTable(id="orgs-table", cursor_type="row") + table.add_columns("#", "Alias", "Username", "Instance URL", "Current") + table.zebra_stripes = True + await container.mount(table) + + for i, org in enumerate(orgs, 1): + current = "●" if org.alias == self.session.current_alias else "" + table.add_row(str(i), org.alias, org.username or "N/A", org.instance_url, current) + + async def _load_config_view(self, container: Container) -> None: + """Load configuration view.""" + await container.mount(Static("Configuration view - TODO")) + + async def refresh_data() -> None: + """Refresh current view.""" + await self._load_view(self._current_view) + + async def action_escape(self) -> None: + """Handle escape - clear detail panel.""" + detail = self.query_one("#detail-panel", DetailPanel) + detail.clear() + + async def action_command_palette(self) -> None: + """Show command palette - TODO in Phase 4.""" + self.notify("Command palette coming in Phase 4", severity="information") +``` + +--- + +### 9. Detail Panel Widget + +**File**: `tcrm_toolkit/interactive/widgets/detail_panel.py` + +```python +"""Detail panel widget for showing entity details.""" + +from textual.containers import Vertical +from textual.widgets import Static, DataTable, Label +from textual.widget import Widget + + +class DetailPanel(Widget): + """Right-side detail panel for showing selected item details.""" + + def __init__(self): + super().__init__(id="detail-panel") + self._content = Static("Select an item to view details", id="detail-content") + + def compose(self) -> ComposeResult: + yield Vertical( + Label("Details", id="detail-title"), + self._content, + id="detail-container" + ) + + def show_dataset(self, dataset) -> None: + """Show dataset details.""" + from tcrm_toolkit.core.models import Dataset + if not isinstance(dataset, Dataset): + return + + content = f"""[bold]Dataset Details[/bold] + +[cyan]ID:[/cyan] {dataset.id} +[cyan]Name:[/cyan] {dataset.name} +[cyan]Label:[/cyan] {dataset.label} +[cyan]Description:[/cyan] {dataset.description or 'N/A'} +[cyan]Status:[/cyan] {dataset.status} +[cyan]Type:[/cyan] {dataset.type} +[cyan]Row Count:[/cyan] {dataset.row_count:, if dataset.row_count else 'N/A'} +[cyan]Created:[/cyan] {dataset.created_date.strftime('%Y-%m-%d %H:%M') if dataset.created_date else 'N/A'} +[cyan]Last Modified:[/cyan] {dataset.last_modified_date.strftime('%Y-%m-%d %H:%M') if dataset.last_modified_date else 'N/A'} +[cyan]Current Version:[/cyan] {dataset.current_version_id or 'N/A'} +""" + self._content.update(content) + + def show_dashboard(self, dashboard) -> None: + """Show dashboard details.""" + from tcrm_toolkit.core.models import Dashboard + if not isinstance(dashboard, Dashboard): + return + + content = f"""[bold]Dashboard Details[/bold] + +[cyan]ID:[/cyan] {dashboard.id} +[cyan]Name:[/cyan] {dashboard.name} +[cyan]Label:[/cyan] {dashboard.label} +[cyan]Description:[/cyan] {dashboard.description or 'N/A'} +[cyan]Folder:[/cyan] {dashboard.folder_name or 'N/A'} +[cyan]Created:[/cyan] {dashboard.created_date.strftime('%Y-%m-%d %H:%M') if dashboard.created_date else 'N/A'} +[cyan]Last Modified:[/cyan] {dashboard.last_modified_date.strftime('%Y-%m-%d %H:%M') if dashboard.last_modified_date else 'N/A'} +""" + self._content.update(content) + + def show_dataflow(self, dataflow) -> None: + """Show dataflow details.""" + from tcrm_toolkit.core.models import Dataflow + if not isinstance(dataflow, Dataflow): + return + + content = f"""[bold]Dataflow Details[/bold] + +[cyan]ID:[/cyan] {dataflow.id} +[cyan]Name:[/cyan] {dataflow.name} +[cyan]Label:[/cyan] {dataflow.label} +[cyan]Description:[/cyan] {dataflow.description or 'N/A'} +[cyan]Status:[/cyan] {dataflow.status} +[cyan]Created:[/cyan] {dataflow.created_date.strftime('%Y-%m-%d %H:%M') if dataflow.created_date else 'N/A'} +[cyan]Last Modified:[/cyan] {dataflow.last_modified_date.strftime('%Y-%m-%d %H:%M') if dataflow.last_modified_date else 'N/A'} +""" + self._content.update(content) + + def clear(self) -> None: + """Clear detail panel.""" + self._content.update("Select an item to view details") +``` + +--- + +### 10. Update CLI Entry Point + +**File**: `tcrm_toolkit/cli/main.py` (MODIFY) + +Add interactive command: + +```python +# Add to imports +from tcrm_toolkit.interactive import TCRMApp + +# Add new command +@app.command() +def interactive() -> None: + """Launch interactive TUI mode.""" + TCRMApp().run() + +# Modify callback to default to interactive if no args +@app.callback(invoke_without_command=True) +def callback( + ctx: typer.Context, + verbose: bool = typer.Option(False, "--verbose", "-v"), + version: bool = typer.Option(False, "--version"), + interactive: bool = typer.Option(False, "--interactive", "-i", help="Launch interactive TUI"), +) -> None: + if version: + from tcrm_toolkit import __version__ + console.print(f"tcrm-toolkit version {__version__}") + raise typer.Exit() + + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + + # Default to interactive if no subcommand + if ctx.invoked_subcommand is None and not interactive: + # Check if running in interactive terminal + import sys + if sys.stdin.isatty() and sys.stdout.isatty(): + TCRMApp().run() + else: + ctx.invoke(app["--help"]) +``` + +--- + +## ✅ Acceptance Criteria + +| Feature | Verification | +|---------|--------------| +| SessionManager initializes | `tcrm` starts, loads orgs from SF CLI | +| Multi-org switching | Ctrl+O shows org list, switches successfully | +| Safety monitor runs | Startup shows 🟢/🟡/🔴 in status bar | +| Critical risk blocks | VPN detected → modal appears, API calls blocked | +| Login screen works | First run → SF CLI web login → persistent session | +| Main screen loads | Sidebar navigation works, tables populate | +| Detail panel updates | Click row → details show on right | +| Cross-platform | Runs on Windows, Linux, macOS | +| Docker works | `docker compose run prod` launches TUI | + +--- + +## 🔧 Coding Agent Instructions + +### Implementation Order +1. **platform.py** - Cross-platform utilities (Phase 0, but needed here) +2. **safety.py** - SafetyMonitor with all 4 checks +3. **session.py** - SessionManager wrapping SFCLIAuthService +4. **widgets/status_bar.py** - Status bar with safety indicator +5. **screens/login_screen.py** - Modal login +6. **screens/org_picker.py** - Org switcher +7. **screens/safety_modal.py** - Critical risk modal +8. **widgets/detail_panel.py** - Detail panel +9. **screens/main_screen.py** - Main layout with navigation +10. **app.py** - TCRMApp tying everything together +11. **cli/main.py** - Add interactive command + +### Key Patterns +- **Async throughout**: All I/O uses `async/await` +- **Error handling**: Try/except with user-friendly notifications +- **Background workers**: Use `@work(exclusive=True)` for data loading +- **Context managers**: `async with session.client_context()` for clients +- **Safety first**: Every API call goes through `session.get_client()` which checks safety + +### Cross-Platform Notes +- SafetyMonitor uses platform-specific commands (PowerShell on Windows, `/sys/class/net` on Linux) +- All paths via `tcrm_toolkit.core.platform` utilities +- Textual handles terminal differences automatically + +### Testing +```bash +# Unit tests +pytest tests/unit/test_safety_monitor.py -v +pytest tests/unit/test_session_manager.py -v + +# Integration test +tcrm # Should launch TUI +# Ctrl+O to test org picker +# Check status bar safety indicator +``` + +--- + +## 📝 Architecture Decisions (Log in `architecture-decisions.md`) + +- [ ] Decision: SessionManager wraps SFCLIAuthService (not replace) +- [ ] Decision: SafetyMonitor fails open on check errors (don't block on false positives) +- [ ] Decision: Hard block on CRITICAL risk (career-ending consequence) +- [ ] Decision: Textual ModalScreen for login/org picker/safety modal +- [ ] Decision: Background monitoring via asyncio.Task with callback +- [ ] Decision: DataTable for all list views (sortable, keyboard nav) + +--- + +*End of Phase 1 Document* \ No newline at end of file diff --git a/docs/plans/phases/phase-2-navigation-browsers.md b/docs/plans/phases/phase-2-navigation-browsers.md new file mode 100644 index 0000000..7f5914d --- /dev/null +++ b/docs/plans/phases/phase-2-navigation-browsers.md @@ -0,0 +1,789 @@ +# Phase 2: Navigation Browsers + +**Document**: `docs/plans/phases/phase-2-navigation-browsers.md` +**Duration**: 1 week +**Branch**: `feature/phase-2-navigation-browsers` (to be created when implementation begins) +**Depends on**: Phase 1 complete + +--- + +## 🎯 Objective + +Build fully functional, keyboard-navigable browsers for Datasets, Dashboards, and Dataflows with: +- Server-side pagination (50 items per page) +- Client-side search/filter +- Column sorting (click headers) +- Row selection with detail panel +- Context menus for actions +- Responsive layout for different terminal sizes + +--- + +## 📋 Explicit Requirements + +### 1. Enhanced DataTable Widget + +**File**: `tcrm_toolkit/interactive/widgets/data_table.py` + +```python +"""Enhanced DataTable with search, filter, sort, and pagination.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any, Callable, Generic, TypeVar + +from textual import on, work +from textual.containers import Container, Horizontal, Vertical +from textual.events import Key +from textual.widgets import DataTable, Input, Label, Static +from textual.widget import Widget + +T = TypeVar("T") + + +@dataclass +class ColumnConfig: + """Configuration for a table column.""" + key: str + title: str + width: int | None = None + sortable: bool = True + filterable: bool = True + formatter: Callable[[Any], str] | None = None + + +class DataBrowser(Widget, Generic[T]): + """ + Generic data browser with: + - Search/filter input + - Sortable columns (click header) + - Server-side pagination + - Row selection → detail panel + - Keyboard navigation (j/k, enter, /, escape) + - Context menu (right-click or Ctrl+M) + """ + + BINDINGS = [ + ("/", "focus_search", "Search"), + ("escape", "clear_search", "Clear Search"), + ("enter", "select_row", "Select"), + ("j", "cursor_down", "Down"), + ("k", "cursor_up", "Up"), + ("ctrl+m", "context_menu", "Context Menu"), + ] + + def __init__( + self, + columns: list[ColumnConfig], + load_data: Callable[[int, int, str | None, str | None], asyncio.coroutine], + get_row_id: Callable[[T], str], + get_row_data: Callable[[T], dict[str, Any]], + title: str = "Data Browser", + page_size: int = 50, + id: str | None = None, + ): + super().__init__(id=id) + self.columns = columns + self.load_data = load_data + self.get_row_id = get_row_id + self.get_row_data = get_row_data + self.title = title + self.page_size = page_size + + # State + self._all_rows: list[T] = [] + self._filtered_rows: list[T] = [] + self._current_page = 0 + self._total_pages = 0 + self._total_count = 0 + self._search_query = "" + self._sort_column: str | None = None + self._sort_reverse = False + self._loading = False + + def compose(self) -> ComposeResult: + yield Vertical( + Horizontal( + Label(self.title, id="browser-title"), + Input(placeholder="Search... (Press /)", id="search-input"), + Static("", id="pagination-info"), + id="browser-header" + ), + Container( + DataTable(id="data-table", cursor_type="row", zebra_stripes=True), + id="table-container" + ), + Static("", id="browser-status"), + id="browser-container" + ) + + async def on_mount(self) -> None: + """Initialize table columns and load first page.""" + table = self.query_one("#data-table", DataTable) + + # Add columns + for col in self.columns: + table.add_column(col.title, key=col.key, width=col.width) + + # Load initial data + await self._load_page(0) + + @on(DataTable.HeaderSelected, "#data-table") + async def on_header_selected(self, event: DataTable.HeaderSelected) -> None: + """Handle column header click for sorting.""" + column_key = event.column_key.value + + # Find column config + col_config = next((c for c in self.columns if c.key == column_key), None) + if not col_config or not col_config.sortable: + return + + # Toggle sort direction + if self._sort_column == column_key: + self._sort_reverse = not self._sort_reverse + else: + self._sort_column = column_key + self._sort_reverse = False + + # Reload with new sort + await self._load_page(0) + + @on(Input.Changed, "#search-input") + async def on_search_changed(self, event: Input.Changed) -> None: + """Handle search input changes (debounced).""" + self._search_query = event.value + # Debounce: wait 300ms before filtering + await asyncio.sleep(0.3) + if self._search_query == event.value: # Still current + await self._apply_filter() + + async def _apply_filter(self) -> None: + """Apply client-side filter to loaded data.""" + if not self._search_query: + self._filtered_rows = self._all_rows + else: + query = self._search_query.lower() + self._filtered_rows = [ + row for row in self._all_rows + if any( + query in str(self.get_row_data(row).get(col.key, "")).lower() + for col in self.columns if col.filterable + ) + ] + + self._current_page = 0 + await self._render_page() + + async def _load_page(self, page: int) -> None: + """Load page from server.""" + if self._loading: + return + + self._loading = True + self.query_one("#browser-status", Static).update("Loading...") + + try: + # Call load_data with pagination params + offset = page * self.page_size + sort_col = self._sort_column + sort_dir = "desc" if self._sort_reverse else "asc" + + # Load data returns (rows, total_count) + rows, total_count = await self.load_data( + offset=offset, + limit=self.page_size, + search=self._search_query or None, + sort=f"{sort_col}:{sort_dir}" if sort_col else None, + ) + + self._all_rows = rows + self._total_count = total_count + self._total_pages = (total_count + self.page_size - 1) // self.page_size + self._current_page = page + + await self._apply_filter() + + except Exception as e: + self.query_one("#browser-status", Static).update(f"Error: {e}") + finally: + self._loading = False + + async def _render_page(self) -> None: + """Render current page to table.""" + table = self.query_one("#data-table", DataTable) + table.clear() + + start = self._current_page * self.page_size + end = start + self.page_size + page_rows = self._filtered_rows[start:end] + + for i, row in enumerate(page_rows, start + 1): + row_data = self.get_row_data(row) + row_key = self.get_row_id(row) + table.add_row(*[str(row_data.get(col.key, "")) for col in self.columns], key=row_key) + + # Update pagination info + showing = len(page_rows) + self.query_one("#pagination-info", Static).update( + f"Page {self._current_page + 1}/{self._total_pages} | " + f"Showing {start + 1}-{start + showing} of {len(self._filtered_rows)} " + f"(filtered from {self._total_count})" + ) + + self.query_one("#browser-status", Static).update("") + + async def refresh(self) -> None: + """Refresh current page.""" + await self._load_page(self._current_page) + + # Actions + async def action_focus_search(self) -> None: + self.query_one("#search-input", Input).focus() + + async def action_clear_search(self) -> None: + search = self.query_one("#search-input", Input) + if search.value: + search.value = "" + await self._apply_filter() + + async def action_select_row(self) -> None: + """Emit selected row event.""" + table = self.query_one("#data-table", DataTable) + if table.cursor_row >= 0: + row_key = table.get_row_at(table.cursor_row).key + row = next((r for r in self._filtered_rows if self.get_row_id(r) == row_key), None) + if row: + self.post_message(self.RowSelected(row)) + + @dataclass + class RowSelected: + row: T +``` + +--- + +### 2. Dataset Browser Implementation + +**File**: `tcrm_toolkit/interactive/operations/dataset_ops.py` + +```python +"""Dataset operations for Interactive TUI.""" + +from typing import Any + +from tcrm_toolkit.core.services.dataset_service import DatasetService +from tcrm_toolkit.core.models import Dataset +from tcrm_toolkit.interactive.widgets.data_table import DataBrowser, ColumnConfig + + +def create_dataset_browser(session) -> DataBrowser[Dataset]: + """Create configured dataset browser.""" + + columns = [ + ColumnConfig(key="id", title="ID", width=18, formatter=lambda x: x[:15] + "..." if len(x) > 18 else x), + ColumnConfig(key="name", title="Name", width=30), + ColumnConfig(key="label", title="Label", width=30), + ColumnConfig(key="row_count", title="Rows", width=12, formatter=lambda x: f"{x:,}" if x else "N/A"), + ColumnConfig(key="status", title="Status", width=12), + ColumnConfig(key="type", title="Type", width=15), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + """Load datasets with pagination.""" + async with session.client_context() as client: + service = DatasetService(client, session.settings) + + # Use service method with pagination + # Note: DatasetService.list_datasets doesn't support offset/limit directly + # We'll need to fetch all and paginate client-side for now + # TODO: Add server-side pagination to DatasetService + all_datasets = await service.list_datasets(page_size=1000, sort=sort.split(":")[0] if sort else "Mru") + + # Apply search filter server-side if possible + if search: + search_lower = search.lower() + all_datasets = [ + ds for ds in all_datasets + if search_lower in ds.name.lower() + or search_lower in ds.label.lower() + or search_lower in ds.id.lower() + ] + + total = len(all_datasets) + page_data = all_datasets[offset:offset + limit] + + return page_data, total + + def get_row_id(dataset: Dataset) -> str: + return dataset.id + + def get_row_data(dataset: Dataset) -> dict[str, Any]: + return { + "id": dataset.id, + "name": dataset.name, + "label": dataset.label, + "row_count": dataset.row_count or 0, + "status": dataset.status, + "type": dataset.type, + } + + return DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="📊 Datasets", + page_size=50, + id="datasets-browser", + ) +``` + +--- + +### 3. Dashboard Browser Implementation + +**File**: `tcrm_toolkit/interactive/operations/dashboard_ops.py` + +```python +"""Dashboard operations for Interactive TUI.""" + +from typing import Any + +from tcrm_toolkit.core.services.dashboard_service import DashboardService +from tcrm_toolkit.core.models import Dashboard +from tcrm_toolkit.interactive.widgets.data_table import DataBrowser, ColumnConfig + + +def create_dashboard_browser(session) -> DataBrowser[Dashboard]: + """Create configured dashboard browser.""" + + columns = [ + ColumnConfig(key="id", title="ID", width=18, formatter=lambda x: x[:15] + "..." if len(x) > 18 else x), + ColumnConfig(key="name", title="Name", width=30), + ColumnConfig(key="label", title="Label", width=30), + ColumnConfig(key="folder_name", title="Folder", width=25, formatter=lambda x: x or "N/A"), + ColumnConfig(key="created_date", title="Created", width=20, formatter=lambda x: x.strftime("%Y-%m-%d") if x else "N/A"), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + async with session.client_context() as client: + service = DashboardService(client, session.settings) + all_dashboards = await service.list_dashboards(page_size=1000, sort=sort.split(":")[0] if sort else "Mru") + + if search: + search_lower = search.lower() + all_dashboards = [ + db for db in all_dashboards + if search_lower in db.name.lower() + or search_lower in db.label.lower() + or search_lower in (db.folder_name or "").lower() + or search_lower in db.id.lower() + ] + + total = len(all_dashboards) + page_data = all_dashboards[offset:offset + limit] + return page_data, total + + def get_row_id(dashboard: Dashboard) -> str: + return dashboard.id + + def get_row_data(dashboard: Dashboard) -> dict[str, Any]: + return { + "id": dashboard.id, + "name": dashboard.name, + "label": dashboard.label, + "folder_name": dashboard.folder_name or "N/A", + "created_date": dashboard.created_date, + } + + return DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="📈 Dashboards", + page_size=50, + id="dashboards-browser", + ) +``` + +--- + +### 4. Dataflow Browser Implementation + +**File**: `tcrm_toolkit/interactive/operations/dataflow_ops.py` + +```python +"""Dataflow operations for Interactive TUI.""" + +from typing import Any + +from tcrm_toolkit.core.services.dataflow_service import DataflowService +from tcrm_toolkit.core.models import Dataflow, DataflowJob +from tcrm_toolkit.interactive.widgets.data_table import DataBrowser, ColumnConfig + + +def create_dataflow_browser(session) -> DataBrowser[Dataflow]: + """Create configured dataflow browser.""" + + columns = [ + ColumnConfig(key="id", title="ID", width=18, formatter=lambda x: x[:15] + "..." if len(x) > 18 else x), + ColumnConfig(key="name", title="Name", width=30), + ColumnConfig(key="label", title="Label", width=30), + ColumnConfig(key="status", title="Status", width=15), + ColumnConfig(key="created_date", title="Created", width=20, formatter=lambda x: x.strftime("%Y-%m-%d") if x else "N/A"), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + async with session.client_context() as client: + service = DataflowService(client, session.settings) + all_dataflows = await service.list_dataflows() + + if search: + search_lower = search.lower() + all_dataflows = [ + df for df in all_dataflows + if search_lower in df.name.lower() + or search_lower in df.label.lower() + or search_lower in df.id.lower() + ] + + total = len(all_dataflows) + page_data = all_dataflows[offset:offset + limit] + return page_data, total + + def get_row_id(dataflow: Dataflow) -> str: + return dataflow.id + + def get_row_data(dataflow: Dataflow) -> dict[str, Any]: + return { + "id": dataflow.id, + "name": dataflow.name, + "label": dataflow.label, + "status": dataflow.status, + "created_date": dataflow.created_date, + } + + return DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="🔄 Dataflows", + page_size=50, + id="dataflows-browser", + ) + + +def create_dataflow_job_browser(session) -> DataBrowser[DataflowJob]: + """Create configured dataflow job browser with live polling.""" + + columns = [ + ColumnConfig(key="id", title="Job ID", width=18, formatter=lambda x: x[:15] + "..." if len(x) > 18 else x), + ColumnConfig(key="dataflow_name", title="Dataflow", width=30), + ColumnConfig(key="command", title="Command", width=12), + ColumnConfig(key="status", title="Status", width=15), + ColumnConfig(key="start_time", title="Started", width=20, formatter=lambda x: x.strftime("%Y-%m-%d %H:%M") if x else "N/A"), + ColumnConfig(key="end_time", title="Ended", width=20, formatter=lambda x: x.strftime("%Y-%m-%d %H:%M") if x else "N/A"), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + async with session.client_context() as client: + service = DataflowService(client, session.settings) + all_jobs = await service.list_dataflow_jobs() + + if search: + search_lower = search.lower() + all_jobs = [ + job for job in all_jobs + if search_lower in job.dataflow_name.lower() + or search_lower in job.command.lower() + or search_lower in job.status.lower() + or search_lower in job.id.lower() + ] + + # Sort by start_time desc by default + all_jobs.sort(key=lambda j: j.start_time or "", reverse=True) + + total = len(all_jobs) + page_data = all_jobs[offset:offset + limit] + return page_data, total + + def get_row_id(job: DataflowJob) -> str: + return job.id + + def get_row_data(job: DataflowJob) -> dict[str, Any]: + return { + "id": job.id, + "dataflow_name": job.dataflow_name, + "command": job.command, + "status": job.status, + "start_time": job.start_time, + "end_time": job.end_time, + } + + browser = DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="📋 Dataflow Jobs", + page_size=50, + id="jobs-browser", + ) + + # Add auto-refresh for running jobs + original_on_mount = browser.on_mount + + async def on_mount_with_polling(self) -> None: + await original_on_mount() + # Start polling for running jobs + self._poll_task = asyncio.create_task(self._poll_running_jobs()) + + async def _poll_running_jobs(self) -> None: + while True: + await asyncio.sleep(10) # Poll every 10 seconds + # Check if any running jobs in current view + table = self.query_one("#data-table", DataTable) + has_running = any( + "running" in str(table.get_cell_at(row, 3)).lower() + for row in range(table.row_count) + ) + if has_running: + await self.refresh() + + browser.on_mount = on_mount_with_polling.__get__(browser, DataBrowser) + + return browser +``` + +--- + +### 5. Update Main Screen to Use Browsers + +**File**: `tcrm_toolkit/interactive/screens/main_screen.py` (MODIFY) + +```python +# Replace the _load_*_view methods with browser-based versions + +async def _load_datasets_view(self, container: Container) -> None: + """Load datasets browser.""" + from tcrm_toolkit.interactive.operations.dataset_ops import create_dataset_browser + browser = create_dataset_browser(self.session) + await container.mount(browser) + + # Handle row selection + @browser.on(DataBrowser.RowSelected) + async def on_dataset_selected(event: DataBrowser.RowSelected) -> None: + detail = self.query_one("#detail-panel", DetailPanel) + detail.show_dataset(event.row) + +async def _load_dashboards_view(self, container: Container) -> None: + """Load dashboards browser.""" + from tcrm_toolkit.interactive.operations.dashboard_ops import create_dashboard_browser + browser = create_dashboard_browser(self.session) + await container.mount(browser) + + @browser.on(DataBrowser.RowSelected) + async def on_dashboard_selected(event: DataBrowser.RowSelected) -> None: + detail = self.query_one("#detail-panel", DetailPanel) + detail.show_dashboard(event.row) + +async def _load_dataflows_view(self, container: Container) -> None: + """Load dataflows browser.""" + from tcrm_toolkit.interactive.operations.dataflow_ops import create_dataflow_browser + browser = create_dataflow_browser(self.session) + await container.mount(browser) + + @browser.on(DataBrowser.RowSelected) + async def on_dataflow_selected(event: DataBrowser.RowSelected) -> None: + detail = self.query_one("#detail-panel", DetailPanel) + detail.show_dataflow(event.row) + +async def _load_jobs_view(self, container: Container) -> None: + """Load jobs browser with auto-refresh.""" + from tcrm_toolkit.interactive.operations.dataflow_ops import create_dataflow_job_browser + browser = create_dataflow_job_browser(self.session) + await container.mount(browser) + + @browser.on(DataBrowser.RowSelected) + async def on_job_selected(event: DataBrowser.RowSelected) -> None: + detail = self.query_one("#detail-panel", DetailPanel) + detail.show_dataflow_job(event.row) +``` + +--- + +### 6. Detail Panel Extensions + +**File**: `tcrm_toolkit/interactive/widgets/detail_panel.py` (EXTEND) + +```python +# Add to DetailPanel class + +def show_dataflow_job(self, job) -> None: + """Show dataflow job details.""" + from tcrm_toolkit.core.models import DataflowJob + if not isinstance(job, DataflowJob): + return + + content = f"""[bold]Dataflow Job Details[/bold] + +[cyan]Job ID:[/cyan] {job.id} +[cyan]Dataflow:[/cyan] {job.dataflow_name} +[cyan]Command:[/cyan] {job.command} +[cyan]Status:[/cyan] {job.status} +[cyan]Start Time:[/cyan] {job.start_time.strftime('%Y-%m-%d %H:%M') if job.start_time else 'N/A'} +[cyan]End Time:[/cyan] {job.end_time.strftime('%Y-%m-%d %H:%M') if job.end_time else 'N/A'} +[cyan]Duration:[/cyan] {self._format_duration(job.start_time, job.end_time) if job.start_time else 'N/A'} +""" + self._content.update(content) + +def _format_duration(self, start, end) -> str: + if not start or not end: + return "N/A" + delta = end - start + hours = delta.seconds // 3600 + minutes = (delta.seconds % 3600) // 60 + return f"{hours}h {minutes}m" +``` + +--- + +### 7. Context Menu Widget + +**File**: `tcrm_toolkit/interactive/widgets/context_menu.py` (NEW) + +```python +"""Context menu for row actions.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Button, Label, Static + + +class ContextMenu(ModalScreen[str]): + """Context menu for row actions.""" + + def __init__(self, actions: list[tuple[str, str]], x: int, y: int): + super().__init__() + self.actions = actions # List of (label, action_id) + self._x = x + self._y = y + + def compose(self) -> ComposeResult: + yield Container( + Container( + *[Button(label, id=f"action-{i}", variant="default") for i, (label, _) in enumerate(self.actions)], + id="context-menu-items" + ), + id="context-menu" + ) + + def on_mount(self) -> None: + # Position menu at cursor + menu = self.query_one("#context-menu", Container) + menu.styles.offset = (self._x, self._y) + + @on(Button.Pressed) + def on_action_selected(self, event: Button.Pressed) -> None: + if event.button.id and event.button.id.startswith("action-"): + idx = int(event.button.id.split("-")[1]) + if idx < len(self.actions): + _, action_id = self.actions[idx] + self.dismiss(action_id) + + def on_click(self, event) -> None: + # Click outside closes menu + self.dismiss(None) +``` + +--- + +### 8. Keyboard Shortcuts Reference + +Add to sidebar hints or help screen: + +| Key | Action | +|-----|--------| +| `j` / `↓` | Next row | +| `k` / `↑` | Previous row | +| `Enter` | Select row (show details) | +| `/` | Focus search | +| `Escape` | Clear search / Close detail | +| `Click header` | Sort column | +| `Ctrl+M` | Context menu | +| `PgUp` / `PgDn` | Page up/down | +| `Home` / `End` | First/Last row | + +--- + +## ✅ Acceptance Criteria + +| Feature | Verification | +|---------|--------------| +| Dataset browser loads | 50 datasets/page, search works, sort works | +| Dashboard browser loads | 50 dashboards/page, folder column shows | +| Dataflow browser loads | 50 dataflows/page, status column | +| Jobs browser loads | Auto-refreshes running jobs every 10s | +| Row selection | Enter shows details in right panel | +| Search | `/` focuses, type filters instantly | +| Sort | Click column header toggles asc/desc | +| Pagination | Page info shows correctly | +| Keyboard nav | j/k, enter, escape all work | +| Cross-platform | All browsers work on Win/Linux/Mac | + +--- + +## 🔧 Coding Agent Instructions + +### Implementation Order +1. **data_table.py** - Generic DataBrowser widget (core component) +2. **dataset_ops.py** - Dataset browser factory +3. **dashboard_ops.py** - Dashboard browser factory +4. **dataflow_ops.py** - Dataflow + Jobs browser factories +5. **context_menu.py** - Context menu widget +6. **main_screen.py** - Integrate browsers, handle RowSelected events +7. **detail_panel.py** - Add show_dataflow_job method + +### Key Patterns +- **Generic DataBrowser**: Reusable for any entity type +- **Factory functions**: `create_*_browser(session)` return configured browsers +- **Async loading**: `@work(exclusive=True)` for data fetching +- **Event-driven**: `RowSelected` message for detail panel updates +- **Client-side pagination**: Current DatasetService doesn't support server-side offset/limit + +### Performance Notes +- Current implementation fetches all items (page_size=1000) and paginates client-side +- For 1000+ items, consider adding server-side pagination to services +- Search is client-side on loaded data (fast for <5000 items) + +### Testing +```bash +# Test each browser +tcrm # Launch TUI +# Navigate to each view with sidebar +# Test search: press /, type filter +# Test sort: click column headers +# Test selection: Enter on row +# Test jobs auto-refresh: start a dataflow, watch jobs view +``` + +--- + +## 📝 Architecture Decisions (Log in `architecture-decisions.md`) + +- [ ] Decision: Generic DataBrowser widget for all entity types +- [ ] Decision: Client-side pagination (service limitation) +- [ ] Decision: Client-side search on loaded data +- [ ] Decision: DataTable for keyboard navigation + sorting +- [ ] Decision: Factory pattern for browser creation +- [ ] Decision: Auto-refresh for jobs view (10s interval) + +--- + +*End of Phase 2 Document* \ No newline at end of file diff --git a/docs/plans/phases/phase-3-operations-background-tasks.md b/docs/plans/phases/phase-3-operations-background-tasks.md new file mode 100644 index 0000000..5f3f874 --- /dev/null +++ b/docs/plans/phases/phase-3-operations-background-tasks.md @@ -0,0 +1,1388 @@ +# Phase 3: Operations & Background Tasks + +**Document**: `docs/plans/phases/phase-3-operations-background-tasks.md` +**Duration**: 1 week +**Branch**: `feature/phase-3-operations-background-tasks` (to be created when implementation begins) +**Depends on**: Phase 2 complete + +--- + +## 🎯 Objective + +Implement all write operations with background execution, progress tracking, and **parallel dataset extraction/upload** using multiprocessing for CPU-bound work and async for I/O-bound work — matching the performance of the legacy multiprocessing implementation. + +--- + +## 📋 Explicit Requirements + +### 1. TaskRunner - Background Task Infrastructure + +**File**: `tcrm_toolkit/interactive/tasks.py` + +```python +"""Background task runner with progress tracking and history.""" + +import asyncio +import uuid +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Callable, Optional +from concurrent.futures import ProcessPoolExecutor + +import structlog +from textual import work +from textual.message import Message +from textual.widget import Widget + +logger = structlog.get_logger(__name__) + + +class TaskStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class TaskProgress: + """Progress update for a task.""" + task_id: str + status: TaskStatus + current: int = 0 + total: int = 0 + message: str = "" + details: dict = field(default_factory=dict) + started_at: datetime = field(default_factory=datetime.utcnow) + completed_at: Optional[datetime] = None + error: Optional[str] = None + + @property + def percent(self) -> float: + if self.total == 0: + return 0.0 + return min(100.0, (self.current / self.total) * 100) + + @property + def is_finished(self) -> bool: + return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED) + + +@dataclass +class TaskResult: + """Final result of a task.""" + task_id: str + status: TaskStatus + result: Any = None + error: Optional[str] = None + started_at: datetime = field(default_factory=datetime.utcnow) + completed_at: datetime = field(default_factory=datetime.utcnow) + metadata: dict = field(default_factory=dict) + + +class TaskProgressMessage(Message): + """Message for task progress updates.""" + def __init__(self, progress: TaskProgress): + self.progress = progress + super().__init__() + + +class TaskCompletedMessage(Message): + """Message for task completion.""" + def __init__(self, result: TaskResult): + self.result = result + super().__init__() + + +class TaskRunner(Widget): + """ + Background task runner with: + - Async task execution (I/O-bound) + - ProcessPoolExecutor for CPU-bound work (multiprocessing) + - Progress tracking via messages + - Task history with persistence + - Cancellation support + - Max concurrent tasks limit + """ + + def __init__( + self, + max_concurrent: int = 3, + max_history: int = 100, + process_pool_size: int = None, + ): + super().__init__() + self.max_concurrent = max_concurrent + self.max_history = max_history + self.process_pool_size = process_pool_size or min(4, (asyncio.cpu_count() or 4)) + + self._tasks: dict[str, asyncio.Task] = {} + self._progress: dict[str, TaskProgress] = {} + self._history: list[TaskResult] = [] + self._process_pool: ProcessPoolExecutor | None = None + self._semaphore = asyncio.Semaphore(max_concurrent) + + @property + def process_pool(self) -> ProcessPoolExecutor: + """Lazy-initialize process pool.""" + if self._process_pool is None: + self._process_pool = ProcessPoolExecutor(max_workers=self.process_pool_size) + return self._process_pool + + async def run_task( + self, + coro_factory: Callable[[], asyncio.coroutine], + task_id: str | None = None, + name: str = "Task", + progress_callback: Callable[[TaskProgress], None] | None = None, + ) -> TaskResult: + """ + Run a coroutine as a background task. + + Args: + coro_factory: Factory that returns a coroutine (not awaited) + task_id: Optional task ID (generated if not provided) + name: Human-readable task name + progress_callback: Optional callback for progress updates + + Returns: + TaskResult when complete + """ + task_id = task_id or str(uuid.uuid4())[:8] + + # Wait for semaphore (max concurrent limit) + async with self._semaphore: + # Create progress tracker + progress = TaskProgress( + task_id=task_id, + status=TaskStatus.RUNNING, + message=f"Starting {name}...", + ) + self._progress[task_id] = progress + self.post_message(TaskProgressMessage(progress)) + + if progress_callback: + progress_callback(progress) + + # Create and run task + task = asyncio.create_task(self._run_task_impl( + task_id, name, coro_factory, progress, progress_callback + )) + self._tasks[task_id] = task + + try: + result = await task + return result + finally: + self._tasks.pop(task_id, None) + + async def _run_task_impl( + self, + task_id: str, + name: str, + coro_factory: Callable, + progress: TaskProgress, + progress_callback: Callable | None, + ) -> TaskResult: + """Internal task implementation with error handling.""" + try: + # Run the coroutine + coro = coro_factory() + result = await coro + + # Mark completed + progress.status = TaskStatus.COMPLETED + progress.current = progress.total + progress.message = f"{name} completed" + progress.completed_at = datetime.utcnow() + + task_result = TaskResult( + task_id=task_id, + status=TaskStatus.COMPLETED, + result=result, + completed_at=progress.completed_at, + ) + + except asyncio.CancelledError: + progress.status = TaskStatus.CANCELLED + progress.message = f"{name} cancelled" + progress.completed_at = datetime.utcnow() + + task_result = TaskResult( + task_id=task_id, + status=TaskStatus.CANCELLED, + error="Cancelled", + completed_at=progress.completed_at, + ) + raise + + except Exception as e: + logger.error("task_failed", task_id=task_id, name=name, error=str(e)) + progress.status = TaskStatus.FAILED + progress.message = f"{name} failed: {e}" + progress.error = str(e) + progress.completed_at = datetime.utcnow() + + task_result = TaskResult( + task_id=task_id, + status=TaskStatus.FAILED, + error=str(e), + completed_at=progress.completed_at, + ) + + # Update progress + self.post_message(TaskProgressMessage(progress)) + if progress_callback: + progress_callback(progress) + + # Add to history + self._add_to_history(task_result) + + # Notify completion + self.post_message(TaskCompletedMessage(task_result)) + + return task_result + + def _add_to_history(self, result: TaskResult) -> None: + """Add result to history, trim if needed.""" + self._history.append(result) + if len(self._history) > self.max_history: + self._history = self._history[-self.max_history:] + + def get_progress(self, task_id: str) -> TaskProgress | None: + return self._progress.get(task_id) + + def get_all_progress(self) -> list[TaskProgress]: + return list(self._progress.values()) + + def get_history(self) -> list[TaskResult]: + return list(self._history) + + async def cancel_task(self, task_id: str) -> bool: + """Cancel a running task.""" + task = self._tasks.get(task_id) + if task and not task.done(): + task.cancel() + return True + return False + + async def cancel_all(self) -> int: + """Cancel all running tasks.""" + count = 0 + for task in self._tasks.values(): + if not task.done(): + task.cancel() + count += 1 + return count + + async def run_in_process_pool(self, func: Callable, *args, **kwargs) -> Any: + """ + Run a CPU-bound function in the process pool. + + Use this for: pandas operations, CSV merging, data transformations. + The function must be picklable (top-level function, not lambda/closure). + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor(self.process_pool, func, *args, **kwargs) + + async def close(self) -> None: + """Cleanup resources.""" + # Cancel all running tasks + await self.cancel_all() + + # Wait for tasks to finish cancellation + if self._tasks: + await asyncio.gather(*self._tasks.values(), return_exceptions=True) + + # Shutdown process pool + if self._process_pool: + self._process_pool.shutdown(wait=True) + self._process_pool = None + + +# ============================================================================= +# Parallel Dataset Extraction Helpers (CPU-bound, run in process pool) +# ============================================================================= + +def merge_csv_chunks(chunk_paths: list[str], output_path: str) -> dict: + """ + Merge multiple CSV chunks into single file. + + Runs in process pool to bypass GIL for pandas concat. + """ + import pandas as pd + + chunks = [] + total_rows = 0 + + for path in chunk_paths: + df = pd.read_csv(path) + chunks.append(df) + total_rows += len(df) + + if chunks: + combined = pd.concat(chunks, ignore_index=True) + combined.to_csv(output_path, index=False) + + return { + "output_path": output_path, + "total_rows": total_rows, + "chunks_merged": len(chunks), + } + + +def process_csv_chunk(args: tuple) -> dict: + """ + Process a single CSV chunk (for upload). + + Args: (chunk_data, chunk_index, total_chunks) + """ + import pandas as pd + import base64 + + chunk_data, chunk_index, total_chunks = args + df = pd.read_csv(chunk_data) + csv_bytes = df.to_csv(index=False).encode() + b64 = base64.b64encode(csv_bytes).decode() + + return { + "part_number": chunk_index + 1, + "data_file_base64": b64, + "rows": len(df), + } + + +def split_csv_for_parallel(input_path: str, num_chunks: int, output_dir: str) -> list[str]: + """ + Split large CSV into chunks for parallel processing. + + Returns list of chunk file paths. + """ + import pandas as pd + from pathlib import Path + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Read in chunks to avoid memory issues + chunk_paths = [] + chunk_size = 100000 # 100k rows per chunk + + for i, chunk in enumerate(pd.read_csv(input_path, chunksize=chunk_size)): + if i >= num_chunks: + # Merge remaining into last chunk + break + chunk_path = output_dir / f"chunk_{i:04d}.csv" + chunk.to_csv(chunk_path, index=False) + chunk_paths.append(str(chunk_path)) + + return chunk_paths +``` + +--- + +### 2. Parallel Dataset Extraction + +**File**: `tcrm_toolkit/interactive/operations/dataset_extract.py` + +```python +"""Parallel dataset extraction with multiprocessing for large datasets.""" + +import asyncio +import math +import tempfile +from pathlib import Path +from typing import Any, Callable + +import structlog +from tcrm_toolkit.core.services.dataset_service import DatasetService +from tcrm_toolkit.core.models import ExtractionProgress +from tcrm_toolkit.interactive.tasks import TaskRunner, merge_csv_chunks + +logger = structlog.get_logger(__name__) + + +class ParallelDatasetExtractor: + """ + Extract large datasets using parallel SAQL queries and multiprocessing merge. + + Strategy: + 1. Get total row count via SAQL + 2. Calculate optimal chunk size (50k-150k rows) + 3. Run SAQL queries in parallel (async, I/O-bound) with semaphore + 4. Save each chunk to temp CSV + 5. Merge chunks using ProcessPoolExecutor (CPU-bound) + 6. Stream progress updates throughout + """ + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[ExtractionProgress], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + self._temp_dir: Path | None = None + + async def extract( + self, + dataset_id: str, + output_path: Path, + max_concurrent_queries: int = 10, + ) -> dict[str, Any]: + """ + Extract dataset to CSV with parallel processing. + + Args: + dataset_id: Dataset ID to extract + output_path: Output CSV file path + max_concurrent_queries: Max parallel SAQL queries + + Returns: + Dict with extraction stats + """ + # Create temp directory for chunks + self._temp_dir = Path(tempfile.mkdtemp(prefix=f"tcrm_extract_{dataset_id}_")) + + try: + async with self.session.client_context() as client: + service = DatasetService(client, self.session.settings) + + # Get dataset info + dataset = await service.get_dataset(dataset_id) + version_id = dataset.current_version_id + if not version_id: + raise ValueError(f"Dataset {dataset_id} has no current version") + + # Get XMD for field list + xmd = await service.get_dataset_xmd(dataset_id, version_id) + fields = service._extract_fields_from_xmd(xmd) + + if not fields: + raise ValueError("No valid fields found in dataset XMD") + + # Get total row count + total_rows = await service.get_row_count(dataset_id, version_id) + + if total_rows == 0: + # Create empty CSV with headers + import pandas as pd + pd.DataFrame(columns=fields).to_csv(output_path, index=False) + return {"rows": 0, "chunks": 0, "output": str(output_path)} + + # Calculate chunking + chunk_size = service._calculate_chunk_size(total_rows) + total_chunks = math.ceil(total_rows / chunk_size) + + logger.info( + "parallel_extract_started", + dataset_id=dataset_id, + total_rows=total_rows, + chunk_size=chunk_size, + total_chunks=total_chunks, + ) + + # Progress tracking + progress = ExtractionProgress( + total_rows=total_rows, + processed_rows=0, + current_chunk=0, + total_chunks=total_chunks, + status="running", + ) + + # Semaphore for concurrent SAQL queries + semaphore = asyncio.Semaphore(max_concurrent_queries) + + async def extract_chunk(chunk_num: int) -> tuple[int, Path]: + """Extract single chunk.""" + async with semaphore: + offset = chunk_num * chunk_size + saql = service._build_saql_query( + dataset_id, version_id, fields, offset, chunk_size + ) + + response = await client.saql_query(saql) + records = response.get("results", {}).get("records", []) + + if records: + import pandas as pd + chunk_df = pd.DataFrame(records) + chunk_path = self._temp_dir / f"chunk_{chunk_num:04d}.csv" + chunk_df.to_csv(chunk_path, index=False) + return len(records), chunk_path + + return 0, None + + # Extract all chunks in parallel + chunk_tasks = [ + extract_chunk(i) for i in range(total_chunks) + ] + + chunk_results = [] + for i, coro in enumerate(asyncio.as_completed(chunk_tasks)): + rows, chunk_path = await coro + chunk_results.append((rows, chunk_path)) + + # Update progress + progress.processed_rows += rows + progress.current_chunk = i + 1 + + if self.progress_callback: + self.progress_callback(progress) + + # Filter successful chunks + successful_chunks = [p for r, p in chunk_results if r > 0 and p] + total_processed = sum(r for r, _ in chunk_results) + + # Merge chunks in process pool (CPU-bound) + progress.status = "merging" + if self.progress_callback: + self.progress_callback(progress) + + merge_result = await self.task_runner.run_in_process_pool( + merge_csv_chunks, + successful_chunks, + str(output_path), + ) + + progress.status = "completed" + progress.current_chunk = total_chunks + if self.progress_callback: + self.progress_callback(progress) + + logger.info( + "parallel_extract_completed", + dataset_id=dataset_id, + total_rows=total_processed, + chunks=len(successful_chunks), + ) + + return { + "rows": total_processed, + "chunks": len(successful_chunks), + "output": str(output_path), + "merge_result": merge_result, + } + + finally: + # Cleanup temp directory + if self._temp_dir and self._temp_dir.exists(): + import shutil + shutil.rmtree(self._temp_dir, ignore_errors=True) +``` + +--- + +### 3. Parallel Dataset Upload + +**File**: `tcrm_toolkit/interactive/operations/dataset_upload.py` + +```python +"""Parallel dataset upload with multiprocessing for CSV processing.""" + +import asyncio +import math +import tempfile +from pathlib import Path +from typing import Callable + +import structlog +from tcrm_toolkit.core.services.dataset_service import DatasetService +from tcrm_toolkit.core.models import UploadProgress +from tcrm_toolkit.interactive.tasks import TaskRunner, split_csv_for_parallel, process_csv_chunk + +logger = structlog.get_logger(__name__) + + +class ParallelDatasetUploader: + """ + Upload large CSV to dataset using parallel chunk processing. + + Strategy: + 1. Read CSV metadata (first row) + 2. Create InsightsExternalData job + 3. Split CSV into chunks for parallel base64 encoding + 4. Process chunks in ProcessPoolExecutor (CPU-bound) + 5. Upload parts sequentially (API requirement) + 6. Trigger processing + """ + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[UploadProgress], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + + async def upload( + self, + dataset_id: str, + file_path: Path, + dataset_name: str | None = None, + operation: str = "Overwrite", + chunk_size: int = 50000, + max_process_workers: int = 4, + ) -> dict[str, Any]: + """ + Upload CSV to dataset with parallel chunk processing. + """ + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + async with self.session.client_context() as client: + service = DatasetService(client, self.session.settings) + + # Get dataset name if not provided + if not dataset_name: + dataset = await service.get_dataset(dataset_id) + dataset_name = dataset.name + + # Generate metadata from first row + import pandas as pd + first_chunk = pd.read_csv(file_path, nrows=1) + metadata_json = service._generate_metadata_json(first_chunk, dataset_name) + + # Create external data job + job_response = await client.create_insights_external_data( + edgemart_alias=dataset_name, + metadata_json=metadata_json, + operation=operation, + ) + external_data_id = job_response["id"] + + # Count total rows + total_rows = sum(1 for _ in open(file_path)) - 1 + total_parts = math.ceil(total_rows / chunk_size) + + logger.info( + "parallel_upload_started", + dataset_id=dataset_id, + external_data_id=external_data_id, + total_rows=total_rows, + total_parts=total_parts, + ) + + # Progress tracking + progress = UploadProgress( + total_rows=total_rows, + uploaded_rows=0, + current_part=0, + total_parts=total_parts, + status="uploading", + ) + + # Split CSV into chunks for parallel base64 encoding + with tempfile.TemporaryDirectory() as tmpdir: + chunk_paths = await self.task_runner.run_in_process_pool( + split_csv_for_parallel, + str(file_path), + max_process_workers, + tmpdir, + ) + + # Process chunks in parallel (base64 encoding is CPU-bound) + chunk_args = [(path, i, total_parts) for i, path in enumerate(chunk_paths)] + + processed_chunks = await self.task_runner.run_in_process_pool( + self._process_chunks_parallel, + chunk_args, + ) + + # Upload parts sequentially (API requires sequential part numbers) + for i, chunk_result in enumerate(processed_chunks): + await client.upload_insights_external_data_part( + external_data_id=external_data_id, + part_number=chunk_result["part_number"], + data_file_base64=chunk_result["data_file_base64"], + ) + + progress.uploaded_rows += chunk_result["rows"] + progress.current_part = i + 1 + + if self.progress_callback: + self.progress_callback(progress) + + # Process the data + progress.status = "processing" + if self.progress_callback: + self.progress_callback(progress) + + result = await client.process_insights_external_data(external_data_id) + + progress.status = "completed" + progress.current_part = total_parts + if self.progress_callback: + self.progress_callback(progress) + + logger.info( + "parallel_upload_completed", + dataset_id=dataset_id, + external_data_id=external_data_id, + rows=progress.uploaded_rows, + ) + + return { + "rows": progress.uploaded_rows, + "parts": total_parts, + "result": result, + } + + @staticmethod + def _process_chunks_parallel(chunk_args: list[tuple]) -> list[dict]: + """Process multiple chunks in parallel (runs in process pool).""" + from concurrent.futures import ProcessPoolExecutor + from tcrm_toolkit.interactive.tasks import process_csv_chunk + + with ProcessPoolExecutor() as executor: + results = list(executor.map(process_csv_chunk, chunk_args)) + + return results +``` + +--- + +### 4. Dashboard Backup/Restore Operations + +**File**: `tcrm_toolkit/interactive/operations/dashboard_backup.py` + +```python +"""Dashboard backup and restore operations.""" + +import asyncio +import json +from pathlib import Path +from typing import Callable + +import structlog +from tcrm_toolkit.core.services.dashboard_service import DashboardService +from tcrm_toolkit.interactive.tasks import TaskRunner + +logger = structlog.get_logger(__name__) + + +class DashboardBackupManager: + """Manage dashboard backup and restore operations.""" + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[dict], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + + async def backup_dashboard( + self, + dashboard_id: str, + output_path: Path, + ) -> dict[str, Any]: + """Backup single dashboard to JSON file.""" + async with self.session.client_context() as client: + service = DashboardService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "fetching", "dashboard_id": dashboard_id}) + + backup = await service.backup_dashboard(dashboard_id, output_path) + + if self.progress_callback: + self.progress_callback({"status": "completed", "path": str(output_path)}) + + return { + "dashboard_id": dashboard_id, + "dashboard_name": backup.dashboard_name, + "path": str(output_path), + } + + async def backup_all_dashboards( + self, + output_dir: Path, + pattern: str | None = None, + ) -> dict[str, Any]: + """Backup all dashboards to directory.""" + output_dir.mkdir(parents=True, exist_ok=True) + + async with self.session.client_context() as client: + service = DashboardService(client, self.session.settings) + dashboards = await service.list_dashboards() + + if pattern: + import fnmatch + dashboards = [d for d in dashboards if fnmatch.fnmatch(d.label, pattern)] + + results = [] + for i, dashboard in enumerate(dashboards): + if self.progress_callback: + self.progress_callback({ + "status": "backing_up", + "current": i + 1, + "total": len(dashboards), + "dashboard": dashboard.label, + }) + + try: + output_path = output_dir / f"{dashboard.name}.json" + await service.backup_dashboard(dashboard.id, output_path) + results.append({ + "id": dashboard.id, + "name": dashboard.name, + "label": dashboard.label, + "path": str(output_path), + "status": "success", + }) + except Exception as e: + results.append({ + "id": dashboard.id, + "name": dashboard.name, + "label": dashboard.label, + "error": str(e), + "status": "failed", + }) + + if self.progress_callback: + self.progress_callback({"status": "completed", "results": results}) + + return { + "total": len(dashboards), + "successful": sum(1 for r in results if r["status"] == "success"), + "failed": sum(1 for r in results if r["status"] == "failed"), + "results": results, + } + + async def restore_dashboard( + self, + backup_path: Path, + new_name: str | None = None, + ) -> dict[str, Any]: + """Restore dashboard from backup file.""" + async with self.session.client_context() as client: + service = DashboardService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "restoring", "path": str(backup_path)}) + + dashboard = await service.restore_dashboard(backup_path, new_name) + + if self.progress_callback: + self.progress_callback({"status": "completed", "dashboard_id": dashboard.id}) + + return { + "dashboard_id": dashboard.id, + "dashboard_name": dashboard.name, + "source": str(backup_path), + } +``` + +--- + +### 5. Dataflow Control Operations + +**File**: `tcrm_toolkit/interactive/operations/dataflow_control.py` + +```python +"""Dataflow start/stop/monitor operations.""" + +import asyncio +from typing import Callable + +import structlog +from tcrm_toolkit.core.services.dataflow_service import DataflowService +from tcrm_toolkit.interactive.tasks import TaskRunner + +logger = structlog.get_logger(__name__) + + +class DataflowController: + """Control dataflow execution with job monitoring.""" + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[dict], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + + async def start_dataflow(self, dataflow_id: str) -> dict[str, Any]: + """Start dataflow and return job info.""" + async with self.session.client_context() as client: + service = DataflowService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "starting", "dataflow_id": dataflow_id}) + + job = await service.start_dataflow(dataflow_id) + + if self.progress_callback: + self.progress_callback({"status": "started", "job_id": job.id}) + + return { + "job_id": job.id, + "dataflow_id": dataflow_id, + "status": job.status, + } + + async def stop_dataflow(self, dataflow_id: str) -> dict[str, Any]: + """Stop running dataflow.""" + async with self.session.client_context() as client: + service = DataflowService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "stopping", "dataflow_id": dataflow_id}) + + job = await service.stop_dataflow(dataflow_id) + + if self.progress_callback: + self.progress_callback({"status": "stopped", "job_id": job.id}) + + return { + "job_id": job.id, + "dataflow_id": dataflow_id, + "status": job.status, + } + + async def wait_for_job( + self, + job_id: str, + poll_interval: int = 10, + timeout: int = 3600, + ) -> dict[str, Any]: + """Wait for dataflow job to complete with progress updates.""" + async with self.session.client_context() as client: + service = DataflowService(client, self.session.settings) + + start_time = asyncio.get_event_loop().time() + + while True: + job = await service.get_dataflow_job_status(job_id) + if not job: + raise ValueError(f"Job {job_id} not found") + + if self.progress_callback: + self.progress_callback({ + "status": "polling", + "job_id": job_id, + "job_status": job.status, + }) + + if job.status in ("Success", "Failed", "Cancelled"): + if self.progress_callback: + self.progress_callback({ + "status": "completed", + "job_id": job_id, + "final_status": job.status, + }) + return { + "job_id": job.id, + "status": job.status, + "dataflow_name": job.dataflow_name, + } + + if asyncio.get_event_loop().time() - start_time > timeout: + raise TimeoutError(f"Job {job_id} timed out after {timeout}s") + + await asyncio.sleep(poll_interval) +``` + +--- + +### 6. Task History Panel Widget + +**File**: `tcrm_toolkit/interactive/widgets/task_history.py` + +```python +"""Task history panel for viewing past operations.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.widgets import DataTable, Label, Static, TabbedContent, TabPane + +from tcrm_toolkit.interactive.tasks import TaskRunner, TaskResult, TaskStatus + + +class TaskHistoryPanel(Static): + """Panel showing task history with filtering.""" + + def __init__(self, task_runner: TaskRunner): + super().__init__(id="task-history") + self.task_runner = task_runner + self._filter_status: TaskStatus | None = None + + def compose(self) -> ComposeResult: + yield Vertical( + Label("Task History", id="history-title"), + TabbedContent( + TabPane("All", id="tab-all"), + TabPane("Running", id="tab-running"), + TabPane("Completed", id="tab-completed"), + TabPane("Failed", id="tab-failed"), + id="history-tabs" + ), + DataTable(id="history-table", cursor_type="row", zebra_stripes=True), + id="history-container" + ) + + async def on_mount(self) -> None: + table = self.query_one("#history-table", DataTable) + table.add_columns("Time", "Task", "Status", "Duration", "Details") + table.zebra_stripes = True + await self.refresh_history() + + @on(TabbedContent.TabActivated, "#history-tabs") + async def on_tab_changed(self, event: TabbedContent.TabActivated) -> None: + tab_map = { + "tab-all": None, + "tab-running": TaskStatus.RUNNING, + "tab-completed": TaskStatus.COMPLETED, + "tab-failed": TaskStatus.FAILED, + } + self._filter_status = tab_map.get(event.tab.id) + await self.refresh_history() + + async def refresh_history(self) -> None: + """Refresh history table.""" + table = self.query_one("#history-table", DataTable) + table.clear() + + history = self.task_runner.get_history() + + if self._filter_status: + history = [r for r in history if r.status == self._filter_status] + + # Show most recent first + for result in reversed(history[-100:]): + duration = "" + if result.completed_at and result.started_at: + delta = result.completed_at - result.started_at + duration = f"{delta.total_seconds():.1f}s" + + status_style = { + TaskStatus.COMPLETED: "[green]", + TaskStatus.FAILED: "[red]", + TaskStatus.CANCELLED: "[yellow]", + TaskStatus.RUNNING: "[blue]", + }.get(result.status, "") + + details = result.error or str(result.result)[:50] if result.result else "" + + table.add_row( + result.started_at.strftime("%H:%M:%S"), + result.task_id, + f"{status_style}{result.status.value}[/]", + duration, + details, + ) +``` + +--- + +### 7. Progress Panel Widget + +**File**: `tcrm_toolkit/interactive/widgets/progress_panel.py` + +```python +"""Progress panel for showing running task progress.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.widgets import ProgressBar, Label, Static, DataTable + +from tcrm_toolkit.interactive.tasks import TaskRunner, TaskProgress, TaskStatus + + +class ProgressPanel(Static): + """Panel showing active task progress bars.""" + + def __init__(self, task_runner: TaskRunner): + super().__init__(id="progress-panel") + self.task_runner = task_runner + + def compose(self) -> ComposeResult: + yield Vertical( + Label("Active Tasks", id="progress-title"), + DataTable(id="progress-table", cursor_type="row"), + id="progress-container" + ) + + async def on_mount(self) -> None: + table = self.query_one("#progress-table", DataTable) + table.add_columns("Task", "Status", "Progress", "Details") + # Start update timer + self.set_interval(1.0, self.update_progress) + + def update_progress(self) -> None: + """Update progress table from task runner.""" + table = self.query_one("#progress-table", DataTable) + table.clear() + + for progress in self.task_runner.get_all_progress(): + if progress.is_finished: + continue + + pct = progress.percent + bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5)) + + table.add_row( + progress.task_id, + progress.status.value, + f"{bar} {pct:.1f}%", + progress.message, + ) +``` + +--- + +### 8. Integrate Operations into Main Screen + +**File**: `tcrm_toolkit/interactive/screens/main_screen.py` (EXTEND) + +```python +# Add context menu handling and operation triggers + +@on(DataTable.RowSelected, "#datasets-table") +async def on_dataset_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle dataset row selection - show context menu.""" + table = event.data_table + row_key = table.get_row_at(event.cursor_row).key + + # Find dataset in browser + browser = self.query_one("#datasets-browser", DataBrowser) + dataset = next((r for r in browser._filtered_rows if browser.get_row_id(r) == row_key), None) + + if dataset: + detail = self.query_one("#detail-panel", DetailPanel) + detail.show_dataset(dataset) + + # Show context menu on right-click or Ctrl+M + # (Implementation in Phase 4) + +async def action_extract_dataset(self) -> None: + """Extract selected dataset.""" + # Get selected dataset from browser + browser = self.query_one("#datasets-browser", DataBrowser) + table = browser.query_one("#data-table", DataTable) + + if table.cursor_row >= 0: + row_key = table.get_row_at(table.cursor_row).key + dataset = next((r for r in browser._filtered_rows if browser.get_row_id(r) == row_key), None) + + if dataset: + # Show file picker for output (simplified - use default path) + output_path = Path.cwd() / f"{dataset.name}.csv" + + # Run extraction in background + from tcrm_toolkit.interactive.operations.dataset_extract import ParallelDatasetExtractor + extractor = ParallelDatasetExtractor( + self.session, + self.app.task_runner, + progress_callback=self._on_extract_progress, + ) + + self.app.task_runner.run_task( + lambda: extractor.extract(dataset.id, output_path), + name=f"Extract {dataset.name}", + ) + +def _on_extract_progress(self, progress: ExtractionProgress) -> None: + """Handle extraction progress updates.""" + # Update progress panel + progress_panel = self.query_one("#progress-panel", ProgressPanel) + # ProgressPanel updates via TaskRunner messages + pass +``` + +--- + +### 9. Command Palette Actions + +**File**: `tcrm_toolkit/interactive/widgets/command_palette.py` + +```python +"""Command palette for quick action access.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Label, ListItem, ListView, Static + + +class CommandPalette(ModalScreen[str]): + """Command palette (Ctrl+P) for fuzzy action search.""" + + COMMANDS = [ + ("Extract Dataset", "extract_dataset", "📥"), + ("Upload Dataset", "upload_dataset", "📤"), + ("Backup Dashboard", "backup_dashboard", "💾"), + ("Restore Dashboard", "restore_dashboard", "📂"), + ("Start Dataflow", "start_dataflow", "▶️"), + ("Stop Dataflow", "stop_dataflow", "⏹️"), + ("Switch Organization", "switch_org", "🔐"), + ("Refresh Current View", "refresh", "🔄"), + ("Open Settings", "settings", "⚙️"), + ("View Task History", "task_history", "📋"), + ("Check Connection Safety", "safety_check", "🛡️"), + ("Quit", "quit", "❌"), + ] + + def __init__(self): + super().__init__() + self._filtered_commands = self.COMMANDS + + def compose(self) -> ComposeResult: + yield Container( + Vertical( + Static("⌘ Command Palette", id="palette-title"), + Input(placeholder="Type to search commands...", id="palette-input"), + ListView( + *[ListItem(Label(f"{icon} {label}"), id=f"cmd-{action}") + for label, action, icon in self.COMMANDS], + id="palette-list" + ), + id="palette-container" + ), + id="palette-dialog" + ) + + async def on_mount(self) -> None: + self.query_one("#palette-input", Input).focus() + + @on(Input.Changed, "#palette-input") + def on_input_changed(self, event: Input.Changed) -> None: + query = event.value.lower() + list_view = self.query_one("#palette-list", ListView) + + if query: + self._filtered_commands = [ + (label, action, icon) for label, action, icon in self.COMMANDS + if query in label.lower() or query in action.lower() + ] + else: + self._filtered_commands = self.COMMANDS + + # Rebuild list + list_view.clear() + for label, action, icon in self._filtered_commands: + list_view.append(ListItem(Label(f"{icon} {label}"), id=f"cmd-{action}")) + + @on(ListView.Selected, "#palette-list") + def on_command_selected(self, event: ListView.Selected) -> None: + if event.item.id and event.item.id.startswith("cmd-"): + action = event.item.id[4:] + self.dismiss(action) + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss(None) +``` + +--- + +## ✅ Acceptance Criteria + +| Feature | Verification | +|---------|--------------| +| TaskRunner executes async tasks | `task_runner.run_task()` runs coroutine in background | +| ProcessPoolExecutor works | `run_in_process_pool()` executes CPU-bound function | +| Parallel extraction | 1M+ row dataset extracts faster than sequential | +| Progress updates | ProgressPanel shows real-time progress bars | +| Task history | Completed tasks appear in history panel | +| Cancellation | Running tasks can be cancelled | +| Dashboard backup | Single and bulk backup work | +| Dataflow control | Start/stop/wait work with progress | +| Command palette | Ctrl+P shows searchable commands | + +--- + +## 🔧 Coding Agent Instructions + +### Implementation Order +1. **tasks.py** - TaskRunner with ProcessPoolExecutor (core infrastructure) +2. **dataset_extract.py** - ParallelDatasetExtractor with SAQL parallelization +3. **dataset_upload.py** - ParallelDatasetUploader with chunk processing +4. **dashboard_backup.py** - Backup/restore operations +5. **dataflow_control.py** - Start/stop/monitor +6. **progress_panel.py** - Progress display widget +7. **task_history.py** - History panel widget +8. **command_palette.py** - Ctrl+P command palette +9. **main_screen.py** - Integrate operations, context menus + +### Parallel Processing Patterns + +**For I/O-bound (SAQL queries, HTTP requests):** +```python +semaphore = asyncio.Semaphore(10) # Limit concurrency +async with semaphore: + result = await client.saql_query(saql) +``` + +**For CPU-bound (pandas concat, base64 encoding, CSV processing):** +```python +# Define top-level function (picklable) +def merge_csv_chunks(chunk_paths, output_path): + import pandas as pd + combined = pd.concat([pd.read_csv(p) for p in chunk_paths]) + combined.to_csv(output_path, index=False) + +# Run in process pool +await task_runner.run_in_process_pool(merge_csv_chunks, paths, output) +``` + +### Key Principles +- **Async for I/O**: Network requests, API calls, file reads +- **Multiprocessing for CPU**: pandas operations, data transformations, encoding +- **Semaphore for rate limiting**: Respect Salesforce API limits +- **Progress callbacks**: Update UI without blocking +- **Temp directories**: Clean up automatically with `tempfile.TemporaryDirectory()` + +### Testing +```bash +# Test parallel extraction +tcrm # Launch TUI +# Navigate to datasets +# Select large dataset +# Press 'E' or use command palette "Extract Dataset" +# Watch progress panel for parallel chunk downloads +# Verify output CSV has all rows + +# Test parallel upload +# Prepare large CSV (100k+ rows) +# Use command palette "Upload Dataset" +# Watch progress for chunk processing + upload + +# Test task history +# Complete several operations +# Press Ctrl+P -> "View Task History" +# Verify all tasks listed with status +``` + +--- + +## 📝 Architecture Decisions (Log in `architecture-decisions.md`) + +- [x] Decision: TaskRunner uses ProcessPoolExecutor for CPU-bound work +- [x] Decision: Semaphore limits concurrent SAQL queries (default 10) +- [x] Decision: Chunk size 50k-150k rows based on dataset size +- [x] Decision: Temp directory for chunk files, auto-cleanup +- [x] Decision: Sequential part upload (API requirement) after parallel prep +- [x] Decision: Progress via Textual messages (not callbacks) for decoupling +- [x] Decision: Command palette as ModalScreen with fuzzy search + +--- + +*End of Phase 3 Document* \ No newline at end of file diff --git a/docs/plans/phases/phase-4-polish-dx.md b/docs/plans/phases/phase-4-polish-dx.md new file mode 100644 index 0000000..148dbbe --- /dev/null +++ b/docs/plans/phases/phase-4-polish-dx.md @@ -0,0 +1,1534 @@ +# Phase 4: Polish & Developer Experience + +**Document**: `docs/plans/phases/phase-4-polish-dx.md` +**Duration**: 1 week +**Branch**: `feature/phase-4-polish-dx` (to be created when implementation begins) +**Depends on**: Phase 3 complete + +--- + +## 🎯 Objective + +Polish the Interactive TUI with developer-focused features: +- Themes (dark/light/customizable) +- Configuration persistence (window size, column widths, filters) +- Command palette with fuzzy search +- Comprehensive keyboard shortcuts +- Help system and tooltips +- Error handling and user-friendly messages +- `tcrm doctor` command with safety checks +- Unit/integration tests +- Documentation + +--- + +## 📋 Explicit Requirements + +### 1. Theme System + +**File**: `tcrm_toolkit/interactive/styles/default.css` + +```css +/* Default light theme */ +Screen { + background: $surface; + color: $text; +} + +Header { + background: $primary; + color: $text; + dock: top; + height: 3; +} + +Footer { + background: $primary-dark; + color: $text; + dock: bottom; + height: 1; +} + +Static#title { + text-style: bold; + color: $accent; + text-align: center; +} + +Static#status-bar { + background: $surface-lighten-2; + color: $text-muted; + height: 1; + dock: bottom; + padding: 0 1; +} + +Static#sidebar-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +ListView#nav-list { + background: $surface-darken-1; + width: 25; +} + +ListView#nav-list > ListItem { + padding: 1 2; +} + +ListView#nav-list > ListItem.--highlight { + background: $primary; + color: $text; +} + +ListView#nav-list > ListItem:hover { + background: $primary-darken-2; +} + +Container#sidebar { + width: 25; + background: $surface-darken-1; + border-right: solid $primary-darken-3; +} + +Container#content { + width: 1fr; +} + +Container#detail-panel { + width: 30; + border-left: solid $primary-darken-3; + background: $surface-darken-1; +} + +DataTable { + background: $surface; + color: $text; +} + +DataTable > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +DataTable > .datatable--cursor { + background: $primary; + color: $text; +} + +DataTable > .datatable--row--odd { + background: $surface-darken-1; +} + +DataTable > .datatable--row--even { + background: $surface; +} + +Label#detail-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +Static#detail-content { + padding: 1 2; + height: 1fr; + overflow: auto; +} + +ProgressBar { + color: $success; + background: $surface-darken-1; +} + +ProgressBar > .progress-bar--complete { + color: $success; +} + +ProgressBar > .progress-bar--remaining { + color: $surface-darken-2; +} + +Button { + margin: 1 2; + min-width: 10; +} + +Button.--primary { + background: $success; + color: $text; +} + +Button.--primary:hover { + background: $success-darken-2; +} + +Button.--warning { + background: $warning; + color: $text; +} + +Button.--warning:hover { + background: $warning-darken-2; +} + +Button.--error { + background: $error; + color: $text; +} + +Button.--error:hover { + background: $error-darken-2; +} + +Input { + margin: 1 2; + min-width: 20; +} + +Input:focus { + border: tall $primary; +} + +ModalScreen { + align: center middle; +} + +#login-container, #picker-dialog, #safety-dialog, #palette-dialog { + background: $surface; + border: thick $primary; + padding: 2 4; + width: 60; + height: auto; +} + +#login-title, #picker-title, #safety-title, #palette-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; +} + +#login-info, #safety-warning, #safety-details { + color: $text-muted; + text-align: center; + margin: 1 0; +} + +#safety-title { + color: $error; + text-style: bold; +} + +#safety-warning { + color: $warning; +} + +Checkbox { + margin: 1 2; +} + +#safety-buttons { + height: 3; + align: center middle; +} + +#safety-buttons > Button { + margin: 0 1; + min-width: 20; +} + +#status-bar { + layout: horizontal; + overflow: hidden; +} + +#status-bar > Static { + margin: 0 1; +} + +#browser-header { + height: 3; + padding: 1 2; +} + +#browser-title { + text-style: bold; + color: $primary; +} + +#search-input { + width: 30; +} + +#pagination-info { + color: $text-muted; +} + +#browser-status { + color: $warning; + text-align: center; +} + +#history-title, #progress-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +#history-table, #progress-table { + background: $surface; + color: $text; +} + +#history-table > .datatable--header, +#progress-table > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +#history-table > .datatable--cursor, +#progress-table > .datatable--cursor { + background: $primary; + color: $text; +} +``` + +**File**: `tcrm_toolkit/interactive/styles/dark.css` + +```css +/* Dark theme - extends default.css */ +@import "default.css"; + +/* Override colors for dark theme */ +:root { + --background: #0c0c0c; + --surface: #1e1e1e; + --surface-darken-1: #252525; + --surface-darken-2: #2d2d2d; + --surface-lighten-1: #262626; + --surface-lighten-2: #323232; + --primary: #007acc; + --primary-darken-2: #005a9e; + --primary-darken-3: #004780; + --secondary: #6a9955; + --success: #6a9955; + --warning: #d7ba7d; + --error: #f44747; + --text: #d4d4d4; + --text-muted: #858585; + --accent: #4ec9b0; +} +``` + +**File**: `tcrm_toolkit/interactive/styles/light.css` + +```css +/* Light theme - extends default.css */ +@import "default.css"; + +/* Override colors for light theme */ +:root { + --background: #ffffff; + --surface: #f8f8f8; + --surface-darken-1: #eeeeee; + --surface-darken-2: #e0e0e0; + --surface-lighten-1: #ffffff; + --surface-lighten-2: #f2f2f2; + --primary: #0066cc; + --primary-darken-2: #004c99; + --primary-darken-3: #003366; + --secondary: #006600; + --success: #006600; + --warning: #cc9900; + --error: #cc0000; + --text: #222222; + --text-muted: #666666; + --accent: #009900; +} +``` + +**File**: `tcrm_toolkit/interactive/config.py` + +```python +"""TUI-specific configuration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class TUIConfig(BaseSettings): + """Configuration for Interactive TUI.""" + + model_config = SettingsConfigDict( + env_prefix="TCRM_TUI_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # Appearance + theme: Literal["dark", "light", "auto"] = "dark" + keybindings: Literal["vim", "standard"] = "standard" + show_line_numbers: bool = False + + # Layout + sidebar_width: int = 25 + detail_panel_width: int = 30 + status_bar_height: int = 1 + + # Behavior + confirm_destructive: bool = True + auto_refresh_interval: int = 10 # seconds for job monitoring + max_history_items: int = 100 + + # Performance + browser_page_size: int = 50 + search_debounce_ms: int = 300 + + # Paths + config_dir: Path = Field(default_factory=lambda: Path.home() / ".tcrm") + history_file: Path = Field(default_factory=lambda: Path.home() / ".tcrm" / "history.json") + + def __post_init__(self): + self.config_dir.mkdir(parents=True, exist_ok=True) +``` + +--- + +### 2. Configuration Persistence + +**File**: `tcrm_toolkit/interactive/config_manager.py` + +```python +"""Configuration persistence for TUI settings.""" + +import json +from pathlib import Path +from typing import Any + +from tcrm_toolkit.interactive.config import TUIConfig + + +class ConfigManager: + """Manages persistent TUI configuration.""" + + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self.config_file = config_dir / "config.json" + self._config: TUIConfig | None = None + + def load(self) -> TUIConfig: + """Load configuration from file.""" + if self._config is None: + if self.config_file.exists(): + try: + with open(self.config_file) as f: + data = json.load(f) + self._config = TUIConfig(**data) + except Exception: + self._config = TUIConfig() + else: + self._config = TUIConfig() + return self._config + + def save(self, config: TUIConfig) -> None: + """Save configuration to file.""" + self._config = config + self.config_dir.mkdir(parents=True, exist_ok=True) + with open(self.config_file, "w") as f: + json.dump(config.model_dump(), f, indent=2) + + def get(self) -> TUIConfig: + """Get current configuration.""" + if self._config is None: + return self.load() + return self._config + + def update(self, **kwargs) -> None: + """Update configuration values.""" + config = self.get() + for key, value in kwargs.items(): + if hasattr(config, key): + setattr(config, key, value) + self.save(config) +``` + +--- + +### 3. Window State Persistence + +**File**: `tcrm_toolkit/interactive/window_manager.py` + +```python +"""Window state persistence (size, position, splits).""" + +import json +from pathlib import Path +from typing import Any + +from textual.app import App +from textual.geometry import Size + + +class WindowManager: + """Manages window state persistence.""" + + def __init__(self, config_dir: Path): + self.config_dir = config_dir + self.state_file = config_dir / "window_state.json" + + def save_state(self, app: App) -> None: + """Save current window state.""" + try: + state = { + "size": { + "width": app.size.width, + "height": app.size.height, + }, + # Note: Textual doesn't expose split sizes easily + # Would need to query specific containers + } + self.config_dir.mkdir(parents=True, exist_ok=True) + with open(self.state_file, "w") as f: + json.dump(state, f, indent=2) + except Exception: + pass # Fail silently + + def load_state(self) -> dict[str, Any] | None: + """Load window state from file.""" + if not self.state_file.exists(): + return None + + try: + with open(self.state_file) as f: + return json.load(f) + except Exception: + return None + + def apply_state(self, app: App) -> None: + """Apply saved window state.""" + state = self.load_state() + if state and "size" in state: + size = state["size"] + # Note: Textual apps are resized externally + # This is mainly for reference + pass +``` + +--- + +### 4. Column Persistence + +**File**: `tcrm_toolkit/interactive/widgets/data_table.py` (EXTEND) + +Add to DataBrowser class: + +```python +def __init__(self, ..., config_manager: ConfigManager | None = None, browser_id: str = "default"): + # ... existing init ... + self.config_manager = config_manager + self.browser_id = browser_id + self._column_states: dict[str, dict] = {} + + # Load column state + if self.config_manager: + self._load_column_state() + +def _load_column_state(self) -> None: + """Load column widths, visibility, sort order.""" + if not self.config_manager: + return + + try: + states = self.config_manager.get().browser_column_states or {} + self._column_states = states.get(self.browser_id, {}) + + # Apply column widths + for col in self.columns: + if col.key in self._column_states: + width = self._column_states[col.key].get("width") + if width is not None: + col.width = width + except Exception: + pass + +def _save_column_state(self) -> None: + """Save column widths, visibility, sort order.""" + if not self.config_manager: + return + + try: + states = self.config_manager.get().browser_column_states or {} + states[self.browser_id] = self._column_states + + # Update current state + for col in self.columns: + if col.key not in self._column_states: + self._column_states[col.key] = {} + self._column_states[col.key]["width"] = col.width + + self.config_manager.update(browser_column_states=states) + except Exception: + pass + +# Call _save_column_state when columns change +# In on_header_selected method: +@on(DataTable.HeaderSelected, "#data-table") +async def on_header_selected(self, event: DataTable.HeaderSelected) -> None: + # ... existing sort logic ... + await self._load_page(0) + self._save_column_state() # Save after sort change + +# In compose method or when table is created: +# After adding columns, apply saved widths +``` + +--- + +### 5. Help System + +**File**: `tcrm_toolkit/interactive/screens/help_screen.py` + +```python +"""Help screen showing keyboard shortcuts and usage.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical, ScrollableContainer +from textual.screen import Screen +from textual.widgets import Button, Label, ListItem, ListView, Static, TabbedContent, TabPane + + +class HelpScreen(Screen): + """Help screen with keyboard shortcuts and usage guide.""" + + BINDINGS = [ + ("escape", "dismiss", "Close"), + ("q", "dismiss", "Close"), + ] + + def compose(self) -> ComposeResult: + yield Container( + Vertical( + Static("⌨️ TCRM Toolkit - Keyboard Shortcuts", id="help-title"), + TabbedContent( + TabPane("Navigation", id="tab-nav"), + TabPane("Actions", id="tab-actions"), + TabPane("Data Browsers", id="tab-browsers"), + TabPane("General", id="tab-general"), + id="help-tabs" + ), + Button("Close", id="close-btn", variant="primary"), + id="help-container" + ), + id="help-dialog" + ) + + def on_mount(self) -> None: + self._populate_help_tabs() + + def _populate_help_tabs(self) -> None: + # Navigation tab + nav_list = self.query_one("#tab-nav", Vertical) + nav_list.mount(ListView( + *[ListItem(Label(shortcut)) for shortcut in [ + "Tab / Shift+Tab - Navigate between panels", + "Ctrl+O - Organization picker", + "Ctrl+P - Command palette", + "F1 - Help screen", + "Esc - Back / Cancel / Clear search", + "Arrow keys / Vi j/k - Navigate lists", + "Enter - Select / Activate", + "Space - Toggle checkbox", + "Page Up/Down - Scroll pages", + "Home/End - Start/End of list", + ]] + )) + + # Actions tab + actions_list = self.query_one("#tab-actions", Vertical) + actions_list.mount(ListView( + *[ListItem(Label(shortcut)) for shortcut in [ + "E - Extract selected dataset", + "U - Upload to selected dataset", + "B - Backup selected dashboard", + "R - Restore dashboard from backup", + "S - Start selected dataflow", + "T - Stop selected dataflow", + "J - View dataflow jobs", + "D - Delete selected item (with confirmation)", + "Y - Show dependencies", + "C - Copy ID to clipboard", + ]] + )) + + # Browsers tab + browsers_list = self.query_one("#tab-browsers", Vertical) + browsers_list.mount(ListView( + *[ListItem(Label(shortcut)) for shortcut in [ + "/ - Focus search input", + "Escape - Clear search", + "Enter - Apply search (when typing)", + "Click column header - Sort column", + "Shift+Click - Multi-column sort", + "Right-click / Ctrl+M - Context menu", + "Ctrl+C - Copy selected row", + "Ctrl+V - Paste (if applicable)", + ]] + )) + + # General tab + general_list = self.query_one("#tab-general", Vertical) + general_list.mount(ListView( + *[ListItem(Label(shortcut)) for shortcut in [ + "Ctrl+Q - Quit application", + "Ctrl+S - Save layout (experimental)", + "Ctrl+L - Clear screen", + "Ctrl+R - Refresh current view", + "Ctrl+F - Fullscreen toggle (experimental)", + ]] + )) + + @on(Button.Pressed, "#close-btn") + def on_close_pressed(self) -> None: + self.dismiss() + + def action_dismiss(self) -> None: + self.dismiss() +``` + +--- + +### 6. Error Handling & User Messages + +**File**: `tcrm_toolkit/interactive/notifications.py` + +```python +"""Enhanced notification system for TUI.""" + +from textual import work +from textual.app import App +from textual.widget import Widget + +from tcrm_toolkit.core.config import Settings, get_settings + + +class NotificationManager: + """Manages user notifications with levels and persistence.""" + + def __init__(self, app: App): + self.app = app + self.settings = get_settings() + self._notifications: list[dict] = [] + + def notify( + self, + message: str, + title: str = "TCRM Toolkit", + severity: str = "information", + timeout: float | None = None, + sticky: bool = False, + ) -> None: + """ + Show notification to user. + + Args: + message: Notification message + title: Notification title + severity: one of "information", "warning", "error", "success" + timeout: Auto-dismiss after seconds (None for sticky) + sticky: 0) + sticky: Remains until dismissed + """ + # Map severity to Textual notification types + severity_map = { + "information": "information", + "warning": "warning", + "error": "error", + "success": "success", + } + + textual_severity = severity_map.get(severity, "information") + + # Show notification + self.app.notify( + message, + title=title, + severity=textual_severity, + timeout=timeout or (0 if sticky else 3), + ) + + # Store in history + self._notifications.append({ + "timestamp": datetime.utcnow(), + "title": title, + "message": message, + "severity": severity, + "timeout": timeout, + "sticky": sticky, + }) + + # Limit history + if len(self._notifications) > 100: + self._notifications = self._notifications[-100:] + + def info(self, message: str, **kwargs) -> None: + self.notify(message, severity="information", **kwargs) + + def warning(self, message: str, **kwargs) -> None: + self.notify(message, severity="warning", **kwargs) + + def error(self, message: str, **kwargs) -> None: + self.notify(message, severity="error", **kwargs) + + def success(self, message: str, **kwargs) -> None: + self.notify(message, severity="success", **kwargs) + + def get_history(self) -> list[dict]: + return list(self._notifications) + + +# Global notification manager (set in app) +_notification_manager: NotificationManager | None = None + + +def init_notifications(app: App) -> NotificationManager: + """Initialize global notification manager.""" + global _notification_manager + _notification_manager = NotificationManager(app) + return _notification_manager + + +def get_notification_manager() -> NotificationManager: + """Get global notification manager.""" + if _notification_manager is None: + raise RuntimeError("Notification manager not initialized") + return _notification_manager + + +def notify_info(message: str, **kwargs) -> None: + get_notification_manager().info(message, **kwargs) + + +def notify_warning(message: str, **kwargs) -> None: + get_notification_manager().warning(message, **kwargs) + + +def notify_error(message: str, **kwargs) -> None: + get_notification_manager().error(message, **kwargs) + + +def notify_success(message: str, **kwargs) -> None: + get_notification_manager().success(message, **kwargs) +``` + +**Update TCRMApp to use notification manager**: + +**File**: `tcrm_toolkit/interactive/app.py` (ADD) + +```python +# In __init__ +self.notifications = init_notifications(self) + +# Replace self.notify calls with: +self.notifications.success("Authenticated successfully") +self.notifications.warning("Token expired (will auto-refresh)") +self.notifications.error("Failed to load datasets: {e}") +``` + +--- + +### 7. Doctor Command Enhancement + +**File**: `tcrm_toolkit/cli/commands/doctor.py` (ENHANCE) + +```python +"""Enhanced doctor command with safety checks.""" + +import asyncio +import platform +import sys +from pathlib import Path + +import typer +from rich.table import Table +from rich.text import Text + +from tcrm_toolkit.cli.ui import ( + console, + print_header, + print_success, + print_error, + print_warning, + print_info, +) +from tcrm_toolkit.core import get_settings +from tcrm_toolkit.core.auth import SFCLIAuthService +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.core.platform import get_os, is_windows, is_linux, is_macos +from tcrm_toolkit.core.sf_cli import SFCLIManager +from tcrm_toolkit.core.token_store import TokenStore +from tcrm_toolkit.interactive.safety import SafetyMonitor + + +@app.command() +def doctor() -> None: + """Run comprehensive system diagnostics.""" + asyncio.run(_doctor_async()) + + +async def _doctor_async() -> None: + """Async doctor implementation.""" + settings = get_settings() + crypto = create_crypto_manager() + auth_service = SFCLIAuthService(settings, crypto) + safety = SafetyMonitor(settings) + sf_cli = SFCLIManager() + token_store = TokenStore(crypto) + + print_header("System Diagnostics", "CRMA Toolkit Health Check") + + # Run all checks + checks = await asyncio.gather( + _check_python_version(), + _check_dependencies(), + _check_sf_cli(sf_cli), + _check_auth_status(auth_service, token_store), + _check_safety_monitor(safety), + _check_keyring(), + _check_directories(), + _check_network(), + return_exceptions=True, + ) + + # Process results + passed = 0 + total = len(checks) + + table = Table(title="Diagnostic Results", show_header=True) + table.add_column("Check", style="cyan") + table.add_column("Status", style="white") + table.add_column("Details", style="dim") + + check_names = [ + "Python Version", + "Dependencies", + "SF CLI Installation", + "Authentication Status", + "Connection Safety", + "Keyring Access", + "Directory Permissions", + "Network Connectivity", + ] + + for i, result in enumerate(checks): + name = check_names[i] + if isinstance(result, Exception): + status = Text("❌ FAIL", style="red") + details = str(result) + print_error(f"{name}: {details}") + else: + status, details = result + if "PASS" in status: + passed += 1 + print_info(f"{name}: {details}") + else: + print_warning(f"{name}: {details}") + + table.add_row(name, status, details) + + console.print(table) + + # Summary + if passed == total: + print_success(f"All {total} checks passed! System is ready.") + else: + print_warning(f"{passed}/{total} checks passed. {total - passed} issues found.") + print_info("Run 'tcrm interactive' to start TUI despite warnings.") + print_info("Critical issues may prevent certain features from working.") + + +async def _check_python_version() -> tuple[str, str]: + """Check Python version.""" + version = sys.version_info + if version.major == 3 and version.minor >= 11: + return ("[green]✓ PASS[/green]", f"Python {version.major}.{version.minor}.{version.micro}") + return ("[red]✗ FAIL[/red]", f"Python 3.11+ required, got {version.major}.{version.minor}") + + +async def _check_dependencies() -> tuple[str, str]: + """Check required dependencies.""" + required = [ + "textual", + "rich", + "httpx", + "pydantic", + "pydantic-settings", + "keyring", + "cryptography", + "pandas", + "structlog", + "tenacity", + "typer", + ] + + missing = [] + for dep in required: + try: + __import__(dep) + except ImportError: + missing.append(dep) + + if not missing: + return ("[green]✓ PASS[/green]", f"All {len(required)} dependencies installed") + return ("[red]✗ FAIL[/red]", f"Missing: {', '.join(missing)}") + + +async def _check_sf_cli(sf_cli: SFCLIManager) -> tuple[str, str]: + """Check SF CLI installation.""" + if sf_cli.is_available(): + try: + version = await sf_cli.get_org_info() # This will fail if not logged in, but CLI exists + return ("[green]✓ PASS[/green]", "SF CLI installed and accessible") + except Exception: + return ("[green]✓ PASS[/green]", "SF CLI installed (not logged in)") + return ("[red]✗ FAIL[/red]", "SF CLI not found. Install from https://developer.salesforce.com/tools/sfdxcli") + + +async def _check_auth_status(auth_service: SFCLIAuthService, token_store: TokenStore) -> tuple[str, str]: + """Check authentication status.""" + try: + # Check for any valid token + orgs = await auth_service.list_orgs() + if orgs: + # Check if any token is valid + for org in orgs: + alias = org.get("alias", "default") + try: + token = await token_store.load_token(alias) + if token and not token.is_expired(): + return ("[green]✓ PASS[/green]", f"Valid token for {alias}") + except Exception: + continue + return ("[yellow]⚠ WARNING[/yellow]", f"{len(orgs)} orgs found, but tokens may be expired") + return ("[yellow]⚠ WARNING[/yellow]", "No orgs authenticated. Run 'tcrm auth login'") + except Exception as e: + return ("[red]✗ FAIL[/red]", f"Auth check failed: {e}") + + +async def _check_safety_monitor(safety: SafetyMonitor) -> tuple[str, str]: + """Check safety monitor functionality.""" + try: + result = await safety.check_connection_safety() + if result.is_safe: + return ("[green]✓ PASS[/green]", "Connection safe (no VPN/Proxy detected)") + elif result.risk_level == "warning": + return ("[yellow]⚠ WARNING[/yellow]", f"Warning: {result.details}") + else: + return ("[red]✗ FAIL[/red]", f"Critical: {result.details}") + except Exception as e: + return ("[yellow]⚠ WARNING[/yellow]", f"Safety check error: {e}") + + +async def _check_keyring() -> tuple[str, str]: + """Check keyring accessibility.""" + try: + import keyring + keyring.set_password("tcrm-toolkit-test", "test-key", "test-value") + value = keyring.get_password("tcrm-toolkit-test", "test-key") + keyring.delete_password("tcrm-toolkit-test", "test-key") + if value == "test-value": + return ("[green]✓ PASS[/green]", "Keyring accessible and functional") + return ("[red]✗ FAIL[/red]", f"Keyring get/set/delete failed") + except Exception as e: + return ("[red]✗ FAIL[/red]", f"Keyring not accessible: {e}") + + +async def _check_directories() -> tuple[str, str]: + """Check directory permissions.""" + try: + from tcrm_toolkit.interactive.config import TUIConfig + config = TUIConfig() + # Check if we can write to config dir + test_file = config.config_dir / "write_test.tmp" + test_file.write_text("test") + test_file.unlink() + return ("[green]✓ PASS[/green]", f"Config directory writable: {config.config_dir}") + except Exception as e: + return ("[red]✗ FAIL[/red]", f"Directory access failed: {e}") + + +async def _check_network() -> tuple[str, str]: + """Check network connectivity to Salesforce.""" + try: + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + # Try to reach Salesforce login endpoint + response = await client.get("https://login.salesforce.com", follow_redirects=True) + if response.status_code < 500: + return ("[green]✓ PASS[/green]", "Network reachable to Salesforce") + return ("[yellow]⚠ WARNING[/yellow]", f"Network issue: HTTP {response.status_code}") + except httpx.TimeoutException: + return ("[yellow]⚠ WARNING[/yellow]", "Network timeout to Salesforce") + except Exception as e: + return ("[red]✗ FAIL[/red]", f"Network check failed: {e}") +``` + +--- + +### 8. Comprehensive Testing + +**File**: `tests/unit/test_interactive.py` + +```python +"""Unit tests for Interactive TUI components.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from tcrm_toolkit.interactive.session import SessionManager, OrgSession +from tcrm_toolkit.interactive.safety import SafetyMonitor, SafetyResult, RiskLevel +from tcrm_toolkit.interactive.tasks import TaskRunner, TaskProgress, TaskStatus +from tcrm_toolkit.interactive.operations.dataset_extract import ParallelDatasetExtractor +from tcrm_toolkit.interactive.operations.dataset_upload import ParallelDatasetUploader + + +@pytest.fixture +def mock_session(): + """Mock session for testing.""" + session = MagicMock() + session.client_context = AsyncMock() + session.client_context.__aenter__ = AsyncMock(return_value=MagicMock()) + session.client_context.__aexit__ = AsyncMock(return_value=None) + session.settings = MagicMock() + session.settings.safety_block_on_critical = True + session.settings.safety_check_interval = 300 + return session + + +@pytest.fixture +def mock_safety(): + """Mock safety monitor.""" + safety = MagicMock(spec=SafetyMonitor) + safety.check_connection_safety = AsyncMock(return_value=SafetyResult( + is_safe=True, + risk_level=RiskLevel.SAFE, + details="Safe" + )) + return safety + + +@pytest.mark.asyncio +async def test_session_manager_initialization(mock_session, mock_safety): + """Test SessionManager initialization.""" + with patch('tcrm_toolkit.interactive.session.SFCLIAuthService'), \ + patch('tcrm_toolkit.interactive.session.SafetyMonitor', return_value=mock_safety): + + session_manager = SessionManager( + settings=mock_session.settings, + safety_monitor=mock_safety, + ) + session_manager.auth_service = AsyncMock() + session_manager.auth_service.list_orgs = AsyncMock(return_value=[]) + session_manager.auth_service.get_access_token = AsyncMock(side_effect=Exception("No token")) + + # Should not raise exception even with no token + await session_manager.initialize() + assert session_manager is not None + + +@pytest.mark.asyncio +async def test_safety_monitor_critical_blocks(mock_session): + """Test that critical safety risks block operations.""" + safety = SafetyMonitor(mock_session.settings) + safety.check_connection_safety = AsyncMock(return_value=SafetyResult( + is_safe=False, + risk_level=RiskLevel.CRITICAL, + details="VPN detected: tun0" + )) + + session = SessionManager( + settings=mock_session.settings, + safety_monitor=safety, + ) + + with pytest.raises(Exception): # Should raise SafetyError + await session.initialize() + + +@pytest.mark.asyncio +async def test_task_runner_basic(): + """Test TaskRunner basic functionality.""" + runner = TaskRunner(max_concurrent=2) + + async def sample_task(): + await asyncio.sleep(0.1) + return "completed" + + result = await runner.run_task( + lambda: sample_task(), + name="Test Task", + ) + + assert result.status == "completed" + assert result.result == "completed" + + await runner.close() + + +@pytest.mark.asyncio +async def test_parallel_extractor_init(mock_session): + """Test ParallelDatasetExtractor initialization.""" + task_runner = TaskRunner() + + extractor = ParallelDatasetExtractor( + session=mock_session, + task_runner=task_runner, + ) + + assert extractor.session == mock_session + assert extractor.task_runner == task_runner + + await task_runner.close() +``` + +--- + +### 9. Documentation + +Create user-facing documentation: + +**File**: `docs/user-guide.md` + +```markdown +# CRM Toolkit User Guide + +## Getting Started + +### Installation + +```bash +# Clone repository +git clone +cd crma-toolkit + +# Install with interactive dependencies +uv sync --extra interactive --extra dev + +# Or use Docker (recommended for consistency) +docker compose up -d +docker compose exec dev bash +``` + +### First Launch + +```bash +# Launch interactive TUI +tcrm + +# First run will prompt for SF CLI web authentication +# Make sure SF CLI is installed: https://developer.salesforce.com/tools/sfdxcli +``` + +## Basic Usage + +### Navigation + +- **Sidebar**: Use arrow keys or `j/k` to navigate between sections +- **Ctrl+O**: Organization picker (switch between SF CLI aliases) +- **Ctrl+P**: Command palette (fuzzy search all actions) +- **Tab/Shift+Tab**: Move between panels (sidebar, content, detail) +- **Esc**: Go back, clear search, or close detail panel + +### Data Browsers + +- **Datasets**: View, extract, upload, delete datasets +- **Dashboards**: View, backup, restore, delete dashboards +- **Dataflows**: View, start, stop, monitor jobs +- **Jobs**: Monitor dataflow execution with auto-refresh + +### Search & Sort + +- **/**: Focus search input +- **Type**: Filter results in real-time +- **Click column header**: Sort by that column +- **Shift+Click**: Multi-column sort +- **Escape**: Clear search + +### Actions + +Once you've selected a row (highlighted), you can: + +- **Enter**: Show details in right panel +- **E**: Extract dataset to CSV +- **U**: Upload CSV to dataset +- **B**: Backup dashboard to JSON +- **R**: Restore dashboard from backup +- **S**: Start dataflow execution +- **T**: Stop running dataflow +- **Y**: Show dependencies (what uses this item) +- **D**: Delete item (with confirmation) +- **C**: Copy ID to clipboard + +## Advanced Features + +### Background Tasks + +Long-running operations (extract, upload, backup) run in the background: + +- View progress in the bottom status bar +- See active tasks in the Progress Panel (access via Command Palette) +- View completed/failed tasks in Task History (Command Palette → "View Task History") + +### Connection Safety + +The tool continuously monitors your connection for VPN/Proxy that could trigger Salesforce blocks: + +- **🟢 Green**: Connection safe +- **🟡 Yellow**: Warning (e.g., system proxy set but IP clean) +- **🔴 Red**: Critical (VPN/Proxy/Tor detected) - blocks all Salesforce API calls + +If a critical risk is detected: +1. A modal dialog appears explaining the risk +2. You must either disconnect the VPN/Proxy or acknowledge the risk +3. Salesforce will IMMEDIATELY disable users detected on VPN/Proxy + +### Multi-Org Support + +- Configure multiple orgs using `sf org login web --alias ` +- Switch between orgs instantly with Ctrl+O +- Each org maintains its own authenticated session +- Tokens are securely stored in your system keyring + +### Themes + +- **Dark theme**: Default (easy on the eyes for extended use) +- **Light theme**: For bright environments +- **Auto**: Follows system theme setting +- Change with: `TCRM_TUI_THEME=dark|light|auto tcrm` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `TCRM_TUI_THEME` | Theme: dark, light, auto | dark | +| `TCRM_TUI_KEYBINDINGS` | Keybinding style: vim, standard | standard | +| `TCRM_TUI_AUTO_REFRESH_INTERVAL` | Job monitor poll interval (seconds) | 10 | +| `TCRM_TUI_MAX_HISTORY_ITEMS` | Max task history items | 100 | +| `TCRM_TUI_BROWSER_PAGE_SIZE` | Items per page in browsers | 50 | +| `SAFETY_CHECK_ENABLED` | Enable VPN/Proxy detection | true | +| `SAFETY_CHECK_INTERVAL` | Seconds between safety checks | 300 | +| `SAFETY_BLOCK_ON_CRITICAL` | Block API calls on critical risk | true | +| `SAFETY_ALLOWLIST_IPS` | Comma-separated IPs to skip checks | (empty) | + +### Persistent Settings + +Window size, column widths, filters, and last-viewed items are automatically saved to: +- `~/.tcrm/config.json` (TUI settings) +- `~/.tcrm/window_state.json` (window state) +- `~/.tcrm/history.json` (task history) + +## Troubleshooting + +### Common Issues + +**SF CLI not found** +``` +Error: SF CLI not found. Install from https://developer.salesforce.com/tools/sfdxcli +``` +Solution: Install SF CLI from the Salesforce developer site. + +**Keyring access denied** +``` +Error: Keyring not accessible +``` +Solution: On Linux, install `gnome-keyring` or `kwallet`. On Windows/macOS, keyring should work out-of-the-box. + +**Connection blocked by safety monitor** +``` +Error: Unsafe connection: VPN detected: tun0 +``` +Solution: Disconnect your VPN/Proxy and retry. If you're on a trusted network, add your IP to `SAFETY_ALLOWLIST_IPS`. + +**No orgs found** +``` +Warning: No orgs authenticated. Run 'tcrm auth login' first +``` +Solution: Run `tcrm auth login` or launch TUI which will prompt for login. + +### Diagnostics + +Run the built-in diagnostic tool: +```bash +tcrm doctor +``` + +This checks: +- Python version and dependencies +- SF CLI installation +- Authentication status +- Connection safety (VPN/Proxy) +- Keyring access +- Directory permissions +- Network connectivity + +## Keyboard Shortcuts Reference + +### Global +| Shortcut | Action | +|----------|--------| +| `Ctrl+Q` | Quit application | +| `Ctrl+P` | Command palette | +| `Ctrl+O` | Organization picker | +| `Ctrl+R` | Refresh current view | +| `F1` | Help screen | +| `Escape` | Back / Cancel | + +### Navigation +| Shortcut | Action | +|----------|--------| +| `Tab` / `Shift+Tab` | Move between panels | +| `Arrow Up` / `Arrow Down` / `j` / `k` | Navigate lists | +| `Page Up` / `Page Down` | Scroll pages | +| `Home` / `End` | Start/End of list | +| `Enter` | Select / Activate | + +### Data Browsers +| Shortcut | Action | +|----------|--------| +| `/` | Focus search | +| `Escape` | Clear search | +| `Enter` | Apply search | +| `Click header` | Sort column | +| `Shift+Click` | Multi-column sort | +| `Right-click` / `Ctrl+M` | Context menu | +| `Ctrl+C` | Copy selected row | + +### Actions (when row selected) +| Shortcut | Action | +|----------|--------| +| `E` | Extract dataset | +| `U` | Upload dataset | +| `B` | Backup dashboard | +| `R` | Restore dashboard | +| `S` | Start dataflow | +| `T` | Stop dataflow | +| `J` | View jobs | +| `Y` | Show dependencies | +| `D` | Delete item | +| `C` | Copy ID | + +## Docker Usage + +### Development +```bash +# Start development container +docker compose up -d dev +docker compose exec dev bash + +# Inside container: +tcrm # Launch TUI +tcrm doctor # Run diagnostics +``` + +### Production +```bash +# Start production container +docker compose up -d prod + +# Access logs +docker compose logs -f prod + +# Execute commands +docker compose exec prod tcrm +``` + +## Getting Help + +- Check the troubleshooting section above +- Run `tcrm doctor` for system diagnostics +- View logs with `tcrm --verbose` (for CLI mode) or check Docker logs +- Visit the project repository for issues and documentation +``` + +--- + +## ✅ Acceptance Criteria + +| Feature | Verification | +|---------|--------------| +| Themes work | Dark/light/themes switch correctly, colors update | +| Config persistence | Window size, column widths, filters saved/restored | +| Command palette | Ctrl+P shows fuzzy searchable actions | +| Help screen | F1 shows organized keyboard shortcuts | +| Notifications | Success/warning/error/info messages appear appropriately | +| Doctor command | Runs all checks, shows clear pass/fail | +| Unit tests | >80% coverage on new interactive components | +| Documentation | User guide covers installation, usage, troubleshooting | +| Cross-platform | All features work on Windows, Linux, macOS | + +--- + +## 🔧 Coding Agent Instructions + +### Implementation Order +1. **styles/** - Create CSS theme files +2. **config.py** - TUI configuration model +3. **config_manager.py** - Configuration persistence +4. **window_manager.py** - Window state persistence +5. **notifications.py** - Enhanced notification system +6. **help_screen.py** - Help screen with keyboard shortcuts +7. **doctor.py** - Enhanced doctor command +8. **tests/unit/test_interactive.py** - Unit tests +9. **docs/user-guide.md** - User-facing documentation +10. **Main App integration** - Wire up config, notifications, help + +### Key Patterns +- **Configuration**: Use Pydantic Settings with env var support +- **Persistence**: JSON files in `~/.tcrm/` directory +- **Theming**: Textual CSS with variables, extend base/theme.css +- **Notifications**: Wrapper around `app.notify()` with history and severity levels +- **Help**: Tabbed content with categorized shortcuts +- **Doctor**: Async checks with Rich table output + +### Testing +```bash +# Run interactive tests +pytest tests/unit/test_interactive.py -v + +# Manual verification +tcrm # Launch TUI +# Test: Ctrl+P -> type "extract" -> Enter +# Test: F1 -> help screen +# Test: Change theme via env var: TCRM_TUI_THEME=light tcrm +# Test: Resize terminal, restart, check size restored +# Test: Change column width in browser, restart, check width restored +``` + +--- + +## 📝 Architecture Decisions (Log in `architecture-decisions.md`) + +- [ ] Decision: Pydantic Settings for TUI config with env var support +- [ ] Decision: JSON files in ~/.tcrm/ for persistence +- [ ] Decision: Textual CSS themes with base/default.css +- [ ] Decision: Notification manager with history and severity levels +- [ ] Decision: Help screen as ModalScreen with TabbedContent +- [ ] Decision: Doctor command runs all checks in parallel +- [ ] Decision: Unit tests for all new interactive components + +--- + +*End of Phase 4 Document* \ No newline at end of file diff --git a/docs/plans/phases/phase-5-value-add-future.md b/docs/plans/phases/phase-5-value-add-future.md new file mode 100644 index 0000000..914a559 --- /dev/null +++ b/docs/plans/phases/phase-5-value-add-future.md @@ -0,0 +1,573 @@ +# Phase 5: Value-Add Features (Future) + +**Document**: `docs/plans/phases/phase-5-value-add-future.md` +**Status**: Planned for future implementation (Post-MVP) +**Branch**: N/A (features to be implemented after Phase 4) +**Depends on**: Phase 4 complete + +--- + +## 🎯 Objective + +Document high-value features that extend beyond the MVP Interactive TUI. These features are **parked** for future implementation based on user feedback and prioritization. They represent the "power user" capabilities that make this toolkit indispensable for Salesforce Analytics administrators and developers. + +--- + +## 📋 Value-Add Features Overview + +| Feature | Priority | Description | Estimated Effort | +|---------|----------|-------------|------------------| +| **Bulk Backup/Restore** | High | One-click backup/restore of all dashboards, datasets, dataflows | 3-5 days | +| **Dataset Diff/Compare** | High | Compare two datasets or two versions (schema + data) | 5-7 days | +| **Dashboard Versioning** | Medium | Git-like history for dashboards with visual diff | 7-10 days | +| **Data Lineage Graph** | Medium | Visual graph: Dataflow → Dataset → Dashboard → Lens | 10-14 days | +| **Scheduled Operations** | Medium | Cron-style: "Extract dataset X daily at 2am" | 5-7 days | +| **Multi-Org Dashboard Sync** | High | Promote dashboards Sandbox → Prod with ID remapping | 7-10 days | +| **Export Formats** | Medium | CSV, JSON, Parquet, Avro, Excel, SQL INSERTs | 3-5 days | +| **Smart Alerts** | Medium | "Alert me when dataflow fails", "API usage > 80%" | 5-7 days | +| **Operation Scripts** | Low | Record UI actions → replay as YAML/JSON script | 5-7 days | +| **Plugin System** | Low | Custom Python plugins via entry points | 10-14 days | + +--- + +## 🔧 Detailed Feature Specifications + +### 1. Bulk Backup/Restore + +**Description**: Backup or restore all analytics metadata with a single command, preserving folder structure and dependencies. + +**Components**: +- **Backup All**: + - Dashboards → JSON files in folder structure + - Datasets → CSV + metadata JSON + - Dataflows → JSON definitions + - Optional: Compress to ZIP/GZIP +- **Restore All**: + - Restore from backup directory + - Handle ID remapping (optional) + - Preserve folder structure + - Dependency-aware restore order + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/bulk_ops.py +class BulkOperationsManager: + async def backup_all( + self, + output_dir: Path, + include_datasets: bool = True, + include_dashboards: bool = True, + include_dataflows: bool = True, + compress: bool = False, + ) -> BulkBackupResult: + # Implementation using existing services with progress tracking + + async def restore_all( + self, + backup_dir: Path, + remap_ids: bool = False, + dry_run: bool = False, + ) -> BulkRestoreResult: + # Implementation with dependency resolution +``` + +**Value**: Reduces hours of manual work to minutes. Essential for migration, disaster recovery, and version control. + +--- + +### 2. Dataset Diff/Compare + +**Description**: Compare two datasets or two versions of the same dataset, showing schema and data differences. + +**Components**: +- **Schema Diff**: + - Field additions/removals + - Type changes + - Label/description changes + - Nullability changes +- **Data Diff** (sample-based for large datasets): + - Row count difference + - Value distribution comparison + - Sample row differences (first/last 1000 rows) + - Statistical comparison (min/max/avg/stddev) + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/dataset_diff.py +class DatasetDiffEngine: + async def compare_datasets( + self, + dataset_id_a: str, + dataset_id_b: str, + version_a: str | None = None, + version_b: str | None = None, + sample_size: int = 10000, + ) -> DatasetDiffResult: + # Compare XMD/schema + # Sample data for comparison if needed + # Generate human-readable and machine-readable diff +``` + +**Value**: Essential for development, testing, and change management. Answers "What changed between these datasets?" + +--- + +### 3. Dashboard Versioning + +**Description**: Git-like version control for dashboards with branching, merging, and visual diff. + +**Components**: +- **Version Storage**: + - Store dashboard JSON in local Git repo or database + - Metadata: timestamp, user, description, tags +- **Visual Diff**: + - Side-by-side JSON comparison + - Widget-level diff (added/removed/modified) + - Dataset usage changes +- **Branching**: + - Create branches for experimentation + - Merge changes between branches + - Conflict resolution + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/dashboard_version.py +class DashboardVersionControl: + async def create_version( + self, + dashboard_id: str, + description: str = "", + tags: list[str] = [], + ) -> DashboardVersion: + # Store current dashboard state + + async def diff_versions( + self, + version_a: str, + version_b: str, + ) -> DashboardDiff: + # Generate visual diff + + async def checkout_version( + self, + dashboard_id: str, + version_id: str, + ) -> None: + # Restore dashboard to specific version +``` + +**Value**: Enables safe experimentation, rollback, and collaboration on dashboard development. + +--- + +### 4. Data Lineage Graph + +**Description**: Interactive visualization of data flow from source to consumption. + +**Components**: +- **Graph Nodes**: + - Dataflows (processing steps) + - Datasets (storage) + - Dashboards (consumption) + - Lenses/Charts (visualization) + - External Sources (if available) +- **Graph Edges**: + - Dataflow → Dataset (output) + - Dataset → Dataflow (input) + - Dataset → Dashboard (source) + - Dataset → Lens (source) +- **Interactivity**: + - Zoom/pan/navigate + - Node details on hover/click + - Filter by type/status + - Export as image/JSON + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/lineage.py +class LineageEngine: + async def build_lineage_graph( + self, + root_ids: list[str] | None = None, + include_external: bool = False, + ) -> LineageGraph: + # Traverse dependencies outward and inward + # Build nodes and edges + + async def get_upstream_lineage( + self, + item_id: str, + item_type: Literal["dataset", "dashboard", "lens"], + max_depth: int = 10, + ) -> LineageSubgraph: + # Find all sources that feed into this item + + async def get_downstream_lineage( + self, + item_id: str, + item_type: Literal["dataset", "dashboard", "lens"], + max_depth: int = 10, + ) -> LineageSubgraph: + # Find all items that consume this item +``` + +**Value**: Critical for impact analysis ("What happens if I change this field?") and debugging data issues. + +--- + +### 5. Scheduled Operations + +**Description**: Cron-style scheduling for recurring operations. + +**Components**: +- **Components**: +- **Schedule Definition**: + - Cron expressions (standard format) + - Timezone support + - Retry policies + - Notification on completion/failure +- **Supported Operations**: + - Extract dataset → CSV/JSON/Parquet + - Upload CSV → Dataset + - Backup dashboard/dataset/dataflow + - Run dataflow + - Custom scripts +- **Execution Engine**: + - Background scheduler (APScheduler or custom) + - Persistent job store (SQLite) + - Concurrent execution limits + - Logging and audit trail + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/scheduler.py +class OperationScheduler: + async def schedule_operation( + self, + cron_expression: str, + operation: str, + parameters: dict, + timezone: str = "UTC", + ) -> ScheduledOperation: + # Create scheduled job + + async def run_scheduled(self, scheduled_op: ScheduledOperation) -> OperationResult: + # Execute the operation + + async def list_scheduled(self) -> list[ScheduledOperation]: + # Get all scheduled operations +``` + +**Value**: Automates repetitive tasks like daily extracts, weekly backups, monthly reports. + +--- + +### 6. Multi-Org Dashboard Sync + +**Description**: Promote dashboards between orgs (e.g., Sandbox → Production) with intelligent ID remapping. + +**Components**: +- **Dependency Analysis**: + - Identify all datasets used by dashboard + - Map source org IDs → target org IDs + - Handle folder structure differences +- **ID Remapping**: + - Dataset IDs in dashboard JSON + - Dataset references in widgets/queries + - Folder IDs +- **Conflict Resolution**: + - Handle existing dashboard with same name + - Option to overwrite, rename, or skip + - Backup before overwrite + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/org_sync.py +class OrgSyncManager: + async def sync_dashboard( + self, + source_dashboard_id: str, + target_org_alias: str, + target_folder: str | None = None, + conflict_resolution: Literal["skip", "rename", "overwrite"] = "skip", + ) -> SyncResult: + # Analyze dependencies + # Remap IDs + # Create in target org + + async def sync_bulk( + self, + source_org_alias: str, + target_org_alias: str, + pattern: str | None = None, + include_dependencies: bool = True, + ) -> BulkSyncResult: + # Sync multiple dashboards +``` + +**Value**: Eliminates manual recreation of dashboards when promoting changes between environments. + +--- + +### 7. Export Formats + +**Description**: Support multiple export formats beyond CSV for different use cases. + +**Components**: +- **Export Formats**: + - CSV (current) + - JSON (pretty and compact) + - Parquet (columnar, efficient for analytics) + - Avro (schema-based, good for streaming) + - Excel (.xlsx) - for business users + - SQL INSERT statements - for database loading +- **Format Selection**: + - Per-operation basis + - Default configurable + - Automatic based on file extension +- **Metadata Preservation**: + - Schema information in export (where format supports) + - Data types and labels + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/export.py +class ExportEngine: + async def export_dataset( + self, + dataset_id: str, + output_path: Path, + format: Literal["csv", "json", "parquet", "avro", "excel", "sql"], + options: dict = {}, + ) -> ExportResult: + # Extract dataset then convert to target format + + async def export_dashboard( + self, + dashboard_id: str, + output_path: Path, + format: Literal["json", "yaml"], + ) -> ExportResult: + # Export dashboard definition +``` + +**Value**: Enables integration with other systems (data warehouses, BI tools, databases). + +--- + +### 8. Smart Alerts + +**Description**: Proactive monitoring and alerting for critical conditions. + +**Components**: +- **Alert Types**: + - Dataflow failure + - Dataset row count anomaly (sudden drop/spike) + - API usage threshold (e.g., >80% of daily limit) + - Dashboard load time degradation + - Failed login attempts +- **Notification Channels**: + - Email (SMTP) + - Slack webhook + - Microsoft Teams + - PagerDuty + - Webhook (custom) +- **Alert Management**: + - Deduplication (avoid alert storms) + - Escalation policies + - Silence/maintenance windows + - Alert history and analytics + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/alerts.py +class AlertManager: + async def check_alerts(self) -> list[Alert]: + # Evaluate all alert conditions + + async def send_alert( + self, + alert: Alert, + channels: list[NotificationChannel], + ) -> None: + # Send via configured channels + + async def schedule_checks( + self, + interval: int = 300, # 5 minutes + ) -> None: + # Background alert checking +``` + +**Value**: Prevents surprises by notifying teams of issues before they impact business. + +--- + +### 9. Operation Scripts + +**Description**: Record and replay sequences of operations for automation and sharing. + +**Components**: +- **Recording**: + - Capture user actions in TUI + - Generate YAML/JSON script + - Include parameters and timing +- **Playback**: + - Execute scripted operations + - Variable substitution + - Conditional logic (if/else) + - Looping constructs +- **Script Library**: + - Community-shared scripts + - Version control integration + - Script validation and testing + +**Implementation**: +```python +# tcrm_toolkit/interactive/operations/scripting.py +class OperationScriptEngine: + async def record_session( + self, + start_action: str, + end_action: str, + ) -> OperationScript: + # Record user interactions + + async def play_script( + self, + script: OperationScript, + variables: dict = {}, + ) -> ScriptExecutionResult: + # Execute recorded operations + + async def validate_script( + self, + script: OperationScript, + ) -> list[ValidationError]: + # Check script for errors +``` + +**Value**: Enables sharing of complex procedures and reduces training time. + +--- + +### 10. Plugin System + +**Description**: Extensible architecture for custom operations and integrations. + +**Components**: +- **Plugin Interface**: + - Standard base class for plugins + - Hook points (pre/post operation, menu items, etc.) + - Access to services and session +- **Discovery**: + - Entry points via `importlib.metadata` + - Local plugin directory + - Explicit plugin loading +- **Sandboxing**: + - Restricted access to dangerous operations + - Permission system (read-only, read-write, admin) + - Isolation from core functionality +- **Marketplace**: + - Official plugin repository + - Installation/update mechanism + - Rating and review system + +**Implementation**: +```python +# tcrm_toolkit/interactive/plugins/base.py +class PluginBase: + """Base class for all TUI plugins.""" + + name: str + version: str + description: str + author: str + + async def initialize(self, session: SessionManager) -> None: + """Called when plugin is loaded.""" + + async def cleanup(self) -> None: + """Called when plugin is unloaded.""" + + def get_menu_items(self) -> list[MenuItem]: + """Return menu items to add to TUI.""" + + def get_operations(self) -> dict[str, Callable]: + """Return operations to add to command palette.""" +``` + +**Value**: Enables community contributions and customization for specific org needs. + +--- + +## 📈 Implementation Roadmap (Suggested) + +### Release 1.1 (1-2 months post-MVP) +- Bulk Backup/Restore +- Dataset Diff/Compare +- Export Formats (JSON, Parquet, Excel) + +### Release 1.2 (3-4 months post-MVP) +- Dashboard Versioning +- Multi-Org Dashboard Sync +- Scheduled Operations (basic) + +### Release 1.3 (5-6 months post-MVP) +- Data Lineage Graph +- Smart Alerts +- Export Formats (Avro, SQL) + +### Release 1.4 (7-8 months post-MVP) +- Operation Scripts +- Plugin System (basic) +- Enhanced Scheduled Operations (timezone, retry) + +### Release 1.5 (9-10 months post-MVP) +- Advanced Plugin System (hooks, sandboxing) +- Alert Notification Channels (Slack, Email, etc.) +- Performance optimizations and scalability + +--- + +## 🔗 Dependencies and Integration + +### New Dependencies +| Feature | New Dependencies | +|---------|------------------| +| Bulk Backup/Restore | `zipfile`, `gzip` (stdlib), `boto3` (optional for S3) | +| Dataset Diff/Compare | `deepdiff`, `pandas-profiling` (optional) | +| Dashboard Versioning | `GitPython` or `dulwich` | +| Data Lineage Graph | `graphviz`, `pygraphviz` (optional), `networkx` | +| Scheduled Operations | `APScheduler` | +| Export Formats | `openpyxl`, `fastparquet`, `fastavro`, `tabulate` | +| Smart Alerts | `python-slugify`, `jinja2` (for templates) | +| Operation Scripts | `PyYAML`, `jsonschema` | +| Plugin System | `importlib-metadata` (Python <3.8) | + +### Integration Points +All features integrate through: +- **SessionManager** - for authenticated access +- **TaskRunner** - for background execution +- **NotificationManager** - for alerts and progress +- **ConfigManager** - for feature-specific settings +- **Command Palette** - for discoverability +- **Context Menus** - for object-specific actions + +--- + +## 📝 Architecture Decisions (Log in `architecture-decisions.md`) + +- [ ] Decision: Bulk operations use streaming to avoid memory issues +- [ ] Decision: Dataset diff uses sampling for >1M row datasets +- [ ] Decision: Dashboard versioning uses local Git repo by default +- [ ] Decision: Lineage graph caches results for 5 minutes +- [ ] Decision: Scheduled operations use persistent SQLite job store +- [ ] Decision: Export format selection based on file extension +- [ ] Decision: Smart alerts run in background with 5-minute interval +- [ ] Decision: Operation scripts use YAML for human-readability +- [ ] Decision: Plugin system uses importlib.metadata for discovery + +--- + +*End of Phase 5 Document* \ No newline at end of file diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..dee2c71 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,90 @@ +# CRM Toolkit Interactive TUI - User Guide + +**Comprehensive Guide** — Installation, usage, configuration, and troubleshooting for the Interactive TUI with VPN/Proxy Safety Monitor. + +--- + +## 🚀 Quick Start + +```bash +# 1. Install with interactive extras +uv pip install -e ".[interactive,dev]" + +# 2. Run TUI +tcrm # Interactive mode +tcrm doctor # System diagnostics +``` + +## 🎮 Navigation & Keybindings + +### Global Shortcuts +- **Ctrl+Q**: Quit application +- **Ctrl+P**: Command palette (fuzzy search actions) +- **Ctrl+O**: Organization picker (switch org) +- **Ctrl+R**: Refresh current view +- **F1**: Help screen +- **Escape**: Back / Cancel / Clear search + +### Navigation & Browsing +- **Tab / Shift+Tab**: Move between panels +- **Arrow Up / Down / j / k**: Navigate lists +- **Page Up / Page Down**: Scroll pages +- **Home / End**: Start/End of list +- **Enter**: Select / Activate item + +### Data Browsers +- **`/`**: Focus search input +- **`Escape`**: Clear search +- **`Enter`**: Apply search filter +- **`Click Header`**: Sort column + +### Actions (when row selected) +- **`E`**: Extract dataset +- **`U`**: Upload dataset +- **`B`**: Backup dashboard +- **`R`**: Restore dashboard from backup +- **`S`**: Start dataflow execution +- **`T`**: Stop running dataflow +- **`Y`**: Show dependencies (what uses this item) +- **`D`**: Delete item (with confirmation) +- **`C`**: Copy ID to clipboard + +--- + +## ⚙️ Configuration + +### Environment Variables +| Variable | Description | Default | +|----------|-------------|---------| +| `TCRM_TUI_THEME` | Theme: dark, light, auto | dark | +| `TCRM_TUI_KEYBINDINGS` | Keybinding style: vim, standard | standard | +| `TCRM_TUI_AUTO_REFRESH_INTERVAL` | Job monitor poll interval (seconds) | 10 | +| `TCRM_TUI_MAX_HISTORY_ITEMS` | Max task history items | 100 | +| `TCRM_TUI_BROWSER_PAGE_SIZE` | Items per page in browsers | 50 | +| `SAFETY_CHECK_ENABLED` | Enable VPN/Proxy detection | true | +| `SAFETY_CHECK_INTERVAL` | Seconds between safety checks | 300 | +| `SAFETY_BLOCK_ON_CRITICAL` | Block API calls on critical risk | true | +| `SAFETY_ALLOWLIST_IPS` | Comma-separated IPs to skip checks | (empty) | + +### Persistent Settings +Automatically saved to: +- `~/.tcrm/config.json` (TUI settings) +- `~/.tcrm/window_state.json` (window state & preferences) +- `~/.tcrm/history.json` (task history) + +--- + +## 🛡️ Connection Safety Monitor +Salesforce immediately disables users detected on VPN/Proxy. The continuous safety monitor: +- **🟢 Green**: Connection safe +- **🟡 Yellow**: Warning (system proxy set but IP clean) +- **🔴 Red**: Critical (VPN/Proxy/Tor detected) — blocks Salesforce API calls and prompts modal warning. + +--- + +## 🔧 Diagnostics +Run the built-in diagnostic tool: +```bash +tcrm doctor +``` +Checks Python version, SF CLI installation, keyring access, directory permissions, and connection safety. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4f997a3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,141 @@ +[project] +name = "tcrm-toolkit" +version = "0.1.0" +description = "Salesforce Tableau CRM (TCRM) Analytics Toolkit - Async Python CLI" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "GNU Affero General Public License v3.0"} +authors = [ + {name = "Pedro Gagliardi", email = "pg-dev-git@users.noreply.github.com"} +] +keywords = ["salesforce", "tcrm", "analytics", "tableau", "cli", "async"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Affero General Public License v3 (AGPLv3)", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", + "Topic :: Office/Business :: Financial :: Spreadsheet", +] +dependencies = [ + "typer[all]>=0.12.0", + "rich>=13.7.0", + "httpx>=0.27.0", + "pydantic>=2.7.0", + "pydantic-settings>=2.3.0", + "tenacity>=8.2.0", + "cryptography>=42.0.0", + "keyring>=24.3.0", + "structlog>=24.1.0", + "pandas>=2.2.0", + "python-dotenv>=1.0.0", + "authlib>=1.3.0", + "python-jose[cryptography]>=3.3.0", +] + +[project.scripts] +tcrm = "tcrm_toolkit.cli.main:app" + +[project.optional-dependencies] +interactive = [ + "textual>=0.52.0", + "textual-dev>=0.1.0", + "httpx>=0.27.0", +] +dev = [ + "pytest>=8.2.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", + "mypy>=1.10.0", + "ruff>=0.5.0", + "pre-commit>=3.7.0", +] + +[project.urls] +Homepage = "https://github.com/pg-dev-git/foss_analytics_toolkit" +Repository = "https://github.com/pg-dev-git/foss_analytics_toolkit" +Issues = "https://github.com/pg-dev-git/foss_analytics_toolkit/issues" + +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["tcrm_toolkit*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --strict-markers --tb=short" + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +no_implicit_optional = true +strict_optional = true +show_error_codes = true +pretty = true +explicit_package_bases = true +ignore_missing_imports = true + +[tool.ruff] +target-version = "py311" +line-length = 100 +exclude = ["_legacy", ".venv", "build", "dist"] +select = [ + "E", + "W", + "F", + "I", + "B", + "C4", + "UP", + "T20", +] +# E = pycodestyle errors +# W = pycodestyle warnings +# F = pyflakes +# I = isort +# B = flake8-bugbear +# C4 = flake8-comprehensions +# UP = pyupgrade +# T20 = flake8-print +ignore = [ + "E501", + "B008", +] +# E501 = line too long (handled by line-length) +# B008 = function calls in default argument + +[tool.ruff.per-file-ignores] +"tests/*" = ["S101", "S106"] +# S101 = allow assert in tests +# S106 = allow hardcoded passwords in tests + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false + +[tool.coverage.run] +source = ["tcrm_toolkit"] +omit = ["tests/*", "*/__pycache__/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] \ No newline at end of file diff --git a/scripts/docker-dev.sh b/scripts/docker-dev.sh new file mode 100755 index 0000000..4dc200a --- /dev/null +++ b/scripts/docker-dev.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Development helper: start dev container and attach + +set -euo pipefail + +docker compose build dev +docker compose up -d dev +docker compose exec -it dev bash diff --git a/scripts/docker-run.sh b/scripts/docker-run.sh new file mode 100755 index 0000000..e654f1d --- /dev/null +++ b/scripts/docker-run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run TUI in production container + +set -euo pipefail + +docker compose run --rm prod "$@" diff --git a/scripts/seed_session.py b/scripts/seed_session.py new file mode 100644 index 0000000..2e5eded --- /dev/null +++ b/scripts/seed_session.py @@ -0,0 +1,37 @@ +"""Seed live Salesforce session into TokenStore for E2E testing using environment variables.""" + +import asyncio +import os +from datetime import datetime, timedelta, timezone +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.core.auth.token_store import StoredToken, TokenStore + + +async def main() -> None: + access_token = os.getenv("TCRM_ACCESS_TOKEN") + instance_url = os.getenv("TCRM_INSTANCE_URL") + username = os.getenv("TCRM_USERNAME", "default@example.com") + + if not access_token or not instance_url: + print("❌ Please set TCRM_ACCESS_TOKEN and TCRM_INSTANCE_URL environment variables.") + return + + crypto = create_crypto_manager() + store = TokenStore(crypto) + + expires = (datetime.now(timezone.utc) + timedelta(days=365)).isoformat() + + token = StoredToken( + access_token=access_token, + instance_url=instance_url, + username=username, + alias="default", + expires_at=expires, + ) + + await store.save_token(token) + print(f"✅ Live session seeded successfully for user {username}!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/verify-cross-platform.py b/scripts/verify-cross-platform.py new file mode 100755 index 0000000..3acfad2 --- /dev/null +++ b/scripts/verify-cross-platform.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +"""Verify cross-platform compatibility of the codebase.""" + +import platform +import subprocess +import sys +from pathlib import Path + + +def check_python_version(): + """Check Python version >= 3.11.""" + version = sys.version_info + assert version.major == 3 and version.minor >= 11, f"Python 3.11+ required, got {version}" + print(f"[OK] Python {version.major}.{version.minor}.{version.micro}") + +def check_imports(): + """Verify all critical imports work.""" + imports = [ + ("textual", "textual"), + ("rich", "rich"), + ("httpx", "httpx"), + ("pydantic", "pydantic"), + ("pydantic_settings", "pydantic_settings"), + ("keyring", "keyring"), + ("cryptography", "cryptography"), + ("pandas", "pandas"), + ("structlog", "structlog"), + ("tenacity", "tenacity"), + ("typer", "typer"), + ] + + for name, module in imports: + try: + __import__(module) + print(f"[OK] {name}") + except ImportError as e: + print(f"[FAIL] {name}: {e}") + return False + return True + +def check_sf_cli(): + """Check SF CLI availability.""" + try: + result = subprocess.run(["sf", "--version"], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + print(f"[OK] SF CLI: {result.stdout.strip()}") + else: + print("[WARN] SF CLI not found (install from https://developer.salesforce.com/tools/sfdxcli)") + except FileNotFoundError: + print("[WARN] SF CLI not found (install from https://developer.salesforce.com/tools/sfdxcli)") + except Exception as e: + print(f"[WARN] SF CLI check failed: {e}") + +def check_platform_utils(): + """Test platform utilities.""" + sys.path.insert(0, str(Path(__file__).parent.parent)) + from tcrm_toolkit.core.platform import get_config_dir, get_data_dir, get_os + + os_type = get_os() + print(f"[OK] OS detected: {os_type}") + print(f"[OK] Config dir: {get_config_dir()}") + print(f"[OK] Data dir: {get_data_dir()}") + +def main(): + print(f"[INFO] Cross-platform verification for {platform.system()} {platform.machine()}") + print("=" * 60) + + check_python_version() + print() + check_imports() + print() + check_sf_cli() + print() + check_platform_utils() + print() + print("=" * 60) + print("[OK] All checks passed!") + +if __name__ == "__main__": + main() diff --git a/scripts/verify_live_session.py b/scripts/verify_live_session.py new file mode 100644 index 0000000..b08efbe --- /dev/null +++ b/scripts/verify_live_session.py @@ -0,0 +1,31 @@ +"""Verify live Salesforce session by listing datasets.""" + +import asyncio +from tcrm_toolkit.core.config import get_settings +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.interactive.session import SessionManager +from tcrm_toolkit.core.services.dataset_service import DatasetService + + +async def main() -> None: + settings = get_settings() + crypto = create_crypto_manager() + session = SessionManager(settings=settings, crypto=crypto) + + await session.initialize() + print(f"Current Org: {session.current_org}") + + if not session.current_org: + print("❌ No active org session found.") + return + + async with session.client_context() as client: + service = DatasetService(client, settings) + datasets = await service.list_datasets(page_size=10) + print(f"✅ Successfully fetched {len(datasets)} datasets from live Salesforce org!") + for ds in datasets: + print(f" - [{ds.id}] {ds.label} ({ds.name})") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tcrm_toolkit/cli/__init__.py b/tcrm_toolkit/cli/__init__.py new file mode 100644 index 0000000..fc031ba --- /dev/null +++ b/tcrm_toolkit/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI module for TCRM Toolkit.""" + +from tcrm_toolkit.cli.main import app + +__all__ = ["app"] diff --git a/tcrm_toolkit/cli/commands/__init__.py b/tcrm_toolkit/cli/commands/__init__.py new file mode 100644 index 0000000..18afd92 --- /dev/null +++ b/tcrm_toolkit/cli/commands/__init__.py @@ -0,0 +1,15 @@ +"""CLI commands package.""" + +from tcrm_toolkit.cli.commands.auth import app as auth_app +from tcrm_toolkit.cli.commands.dashboards import app as dashboards_app +from tcrm_toolkit.cli.commands.dataflows import app as dataflows_app +from tcrm_toolkit.cli.commands.datasets import app as datasets_app +from tcrm_toolkit.cli.commands.jobs import app as jobs_app + +__all__ = [ + "auth_app", + "datasets_app", + "dashboards_app", + "dataflows_app", + "jobs_app", +] diff --git a/tcrm_toolkit/cli/commands/auth.py b/tcrm_toolkit/cli/commands/auth.py new file mode 100644 index 0000000..c7a2cba --- /dev/null +++ b/tcrm_toolkit/cli/commands/auth.py @@ -0,0 +1,248 @@ +"""Auth CLI commands.""" + +import asyncio + +import typer + +from tcrm_toolkit.cli.ui import ( + print_error, + print_header, + print_info, + print_success, + print_warning, + prompt_text, +) +from tcrm_toolkit.core import get_settings +from tcrm_toolkit.core.auth import SFCLIAuthError, SFCLIAuthService +from tcrm_toolkit.core.crypto import CryptoManager, create_crypto_manager +from tcrm_toolkit.core.models import ( + ConnectedAppConfig, + DeviceFlowConfig, + WebOAuthConfig, +) +from tcrm_toolkit.core.services.auth_service import AuthService + +app = typer.Typer(name="auth", help="Authentication commands") + + +def _get_auth_service() -> AuthService: + """Get configured auth service (OAuth-based).""" + settings = get_settings() + crypto = CryptoManager(settings.encryption_key) + return AuthService(settings, crypto) + + +def _get_sf_cli_auth_service() -> SFCLIAuthService: + """Get configured SF CLI auth service.""" + settings = get_settings() + crypto = create_crypto_manager() + return SFCLIAuthService(settings, crypto) + + +@app.command("login") +def login( + method: str = typer.Option( + "sfcli", + "--method", + "-m", + help="Authentication method: sfcli, device, web, jwt", + ), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias for SF CLI"), + instance_url: str | None = typer.Option(None, "--instance-url", "-r", help="Custom instance URL"), + username: str | None = typer.Option(None, "--username", "-u", help="Username for JWT flow"), +) -> None: + """Authenticate with Salesforce.""" + asyncio.run(_login_async(method, alias, instance_url, username)) + + +async def _login_async( + method: str, + alias: str, + instance_url: str | None, + username: str | None, +) -> None: + """Async login implementation.""" + settings = get_settings() + + try: + if method == "sfcli": + # SF CLI-based authentication (no Connected App needed) + auth_service = _get_sf_cli_auth_service() + + if not auth_service.sf_cli.is_available(): + print_error("SF CLI not found. Install from https://developer.salesforce.com/tools/sfdxcli") + raise typer.Exit(1) + + print_info(f"Starting SF CLI web login (alias: {alias})...") + print_warning("This will open a browser window for authentication") + + token = await auth_service.login( + alias=alias, + instance_url=instance_url, + timeout=300, + ) + + print_success("Authenticated successfully via SF CLI") + print_info(f"Instance URL: {await auth_service.get_instance_url(alias)}") + username = await auth_service.get_username(alias) + if username: + print_info(f"User: {username}") + + elif method == "jwt": + if not settings.has_connected_app_credentials: + print_error("Connected App credentials not configured in .env") + print_info("Set SF_CONNECTED_APP_CLIENT_ID, SF_CONNECTED_APP_CLIENT_SECRET, SF_CONNECTED_APP_USERNAME") + raise typer.Exit(1) + + if not username: + username = prompt_text("Enter Salesforce username") + + auth_service = _get_auth_service() + config = ConnectedAppConfig( + client_id=settings.sf_connected_app_client_id, + client_secret=settings.sf_connected_app_client_secret, + username=username, + ) + + print_info("Authenticating with JWT Bearer flow...") + token = await auth_service.jwt_bearer_login(config) + auth_service.store_tokens(username or token.id, token) + print_success(f"Authenticated successfully as {token.id}") + print_info(f"Instance URL: {token.instance_url}") + + elif method == "web": + if not settings.has_web_oauth_credentials: + print_error("Web OAuth credentials not configured in .env") + print_info("Set SF_WEB_OAUTH_CLIENT_ID, SF_WEB_OAUTH_CLIENT_SECRET") + raise typer.Exit(1) + + auth_service = _get_auth_service() + config = WebOAuthConfig( + client_id=settings.sf_web_oauth_client_id, + client_secret=settings.sf_web_oauth_client_secret, + redirect_uri=settings.sf_web_oauth_redirect_uri, + ) + + print_info("Starting Web PKCE flow...") + print_warning("This will open a browser window for authentication") + token = await auth_service.run_web_pkce_flow(config) + auth_service.store_tokens(username or token.id, token) + print_success(f"Authenticated successfully as {token.id}") + print_info(f"Instance URL: {token.instance_url}") + + elif method == "device": + if not settings.has_device_flow_credentials: + print_error("Device Flow credentials not configured in .env") + print_info("Set SF_DEVICE_FLOW_CLIENT_ID") + raise typer.Exit(1) + + auth_service = _get_auth_service() + config = DeviceFlowConfig( + client_id=settings.sf_device_flow_client_id, + ) + + print_info("Starting Device Authorization Flow...") + token = await auth_service.run_device_flow(config) + auth_service.store_tokens(username or token.id, token) + print_success(f"Authenticated successfully as {token.id}") + print_info(f"Instance URL: {token.instance_url}") + + else: + print_error(f"Unknown method: {method}. Use: sfcli, device, web, jwt") + raise typer.Exit(1) + + except SFCLIAuthError as e: + print_error(f"SF CLI authentication failed: {e}") + raise typer.Exit(1) + except Exception as e: + print_error(f"Authentication failed: {e}") + raise typer.Exit(1) + + +@app.command("logout") +def logout( + alias: str = typer.Argument("default", help="Org alias to logout"), +) -> None: + """Remove stored authentication for an org alias.""" + asyncio.run(_logout_async(alias)) + + +async def _logout_async(alias: str) -> None: + """Async logout implementation.""" + auth_service = _get_sf_cli_auth_service() + + try: + if await auth_service.logout(alias): + print_success(f"Logged out alias '{alias}'") + else: + print_warning(f"No stored credentials found for alias '{alias}'") + except Exception as e: + print_error(f"Logout failed: {e}") + raise typer.Exit(1) + + +@app.command("status") +def status( + alias: str = typer.Option("default", "--alias", "-a", help="Org alias to check"), +) -> None: + """Check authentication status.""" + asyncio.run(_status_async(alias)) + + +async def _status_async(alias: str) -> None: + """Async status implementation.""" + auth_service = _get_sf_cli_auth_service() + + try: + status_info = await auth_service.status(alias) + + if status_info["authenticated"]: + print_success(f"Authenticated: {status_info['alias']}") + if status_info.get("username"): + print_info(f"User: {status_info['username']}") + print_info(f"Instance: {status_info['instance_url']}") + if status_info["token_expired"]: + print_warning("Token expired (will auto-refresh on next use)") + else: + print_info("Token valid") + if status_info.get("expires_at"): + print_info(f"Expires: {status_info['expires_at']}") + else: + print_warning(status_info["message"]) + + if not status_info.get("sf_cli_available", True): + print_warning("SF CLI not available. Install from https://developer.salesforce.com/tools/sfdxcli") + + except Exception as e: + print_error(f"Status check failed: {e}") + raise typer.Exit(1) + + +@app.command("list-orgs") +def list_orgs() -> None: + """List all authorized orgs from SF CLI.""" + asyncio.run(_list_orgs_async()) + + +async def _list_orgs_async() -> None: + """Async list orgs implementation.""" + auth_service = _get_sf_cli_auth_service() + + try: + orgs = await auth_service.list_orgs() + + if not orgs: + print_info("No authorized orgs found") + return + + print_header("Authorized Orgs") + for org in orgs: + alias = org.get("alias", "N/A") + username = org.get("username", "N/A") + instance = org.get("instanceUrl", "N/A") + connected = "✓" if org.get("connectedStatus") == "Connected" else "✗" + print_info(f" {connected} {alias} ({username}) - {instance}") + + except Exception as e: + print_error(f"Failed to list orgs: {e}") + raise typer.Exit(1) diff --git a/tcrm_toolkit/cli/commands/dashboards.py b/tcrm_toolkit/cli/commands/dashboards.py new file mode 100644 index 0000000..b3b41b7 --- /dev/null +++ b/tcrm_toolkit/cli/commands/dashboards.py @@ -0,0 +1,172 @@ +"""Dashboard CLI commands.""" + +import asyncio +from pathlib import Path + +import typer + +from tcrm_toolkit.cli.ui import ( + console, + create_dashboard_table, + print_dashboard_details, + print_error, + print_header, + print_info, + print_success, + prompt_confirm, +) +from tcrm_toolkit.core import SalesforceClient, get_settings +from tcrm_toolkit.core.auth import SFCLIAuthService +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.core.services.dashboard_service import DashboardService + +app = typer.Typer(name="dashboards", help="Dashboard commands") + + +async def _get_client(alias: str = "default") -> SalesforceClient: + """Get authenticated SalesforceClient.""" + settings = get_settings() + crypto = create_crypto_manager() + auth_service = SFCLIAuthService(settings, crypto) + try: + token = await auth_service.get_access_token(alias, auto_refresh=False) + instance_url = await auth_service.get_instance_url(alias) + except Exception as e: + print_error(f"Authentication failed: {e}. Run 'tcrm auth login' first.") + raise typer.Exit(1) + return SalesforceClient(access_token=token, instance_url=instance_url, settings=settings) + + +@app.command("list") +def list_dashboards( + page_size: int = typer.Option(50, "--page-size", "-n", help="Number of dashboards per page"), + sort: str = typer.Option("Mru", "--sort", "-s", help="Sort order: Mru, Name, CreatedDate"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """List all dashboards.""" + asyncio.run(_list_dashboards_async(page_size, sort, alias)) + + +async def _list_dashboards_async(page_size: int, sort: str, alias: str) -> None: + """Async list dashboards implementation.""" + client = await _get_client(alias) + async with client: + service = DashboardService(client, get_settings()) + dashboards = await service.list_dashboards(page_size=page_size, sort=sort) + table = create_dashboard_table(dashboards) + console.print(table) + + +@app.command("get") +def get_dashboard( + dashboard_id: str = typer.Argument(help="Dashboard ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Get dashboard details.""" + asyncio.run(_get_dashboard_async(dashboard_id, alias)) + + +async def _get_dashboard_async(dashboard_id: str, alias: str) -> None: + """Async get dashboard implementation.""" + client = await _get_client(alias) + async with client: + service = DashboardService(client, get_settings()) + dashboard = await service.get_dashboard(dashboard_id) + print_dashboard_details(dashboard) + + +@app.command("backup") +def backup_dashboard( + dashboard_id: str = typer.Argument(help="Dashboard ID"), + output: Path = typer.Option( + None, + "--output", + "-o", + help="Output JSON file path", + ), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Backup dashboard JSON definition.""" + asyncio.run(_backup_dashboard_async(dashboard_id, output, alias)) + + +async def _backup_dashboard_async(dashboard_id: str, output: Path | None, alias: str) -> None: + """Async backup dashboard implementation.""" + if output is None: + output = Path(f"{dashboard_id}_backup.json") + + print_header("Backup Dashboard", f"Dashboard: {dashboard_id} -> {output}") + client = await _get_client(alias) + async with client: + service = DashboardService(client, get_settings()) + await service.backup_dashboard(dashboard_id, output) + print_success(f"Dashboard backed up successfully to {output}") + + +@app.command("restore") +def restore_dashboard( + backup_file: Path = typer.Argument(help="Backup JSON file"), + new_name: str = typer.Option(None, "--name", "-n", help="New dashboard name"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Restore dashboard from backup.""" + asyncio.run(_restore_dashboard_async(backup_file, new_name, alias)) + + +async def _restore_dashboard_async(backup_file: Path, new_name: str | None, alias: str) -> None: + """Async restore dashboard implementation.""" + print_header("Restore Dashboard", f"Backup: {backup_file}") + client = await _get_client(alias) + async with client: + service = DashboardService(client, get_settings()) + dashboard = await service.restore_dashboard(backup_file, new_name) + print_success(f"Dashboard restored successfully as {dashboard.label} (ID: {dashboard.id})") + + +@app.command("delete") +def delete_dashboard( + dashboard_id: str = typer.Argument(help="Dashboard ID"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Delete a dashboard.""" + asyncio.run(_delete_dashboard_async(dashboard_id, force, alias)) + + +async def _delete_dashboard_async(dashboard_id: str, force: bool, alias: str) -> None: + """Async delete dashboard implementation.""" + if not force: + confirm = prompt_confirm(f"Delete dashboard {dashboard_id}? This cannot be undone.") + if not confirm: + print_info("Cancelled") + return + + client = await _get_client(alias) + async with client: + service = DashboardService(client, get_settings()) + await service.delete_dashboard(dashboard_id) + print_success(f"Dashboard {dashboard_id} deleted successfully") + + +@app.command("datasets") +def dashboard_datasets( + dashboard_id: str = typer.Argument(help="Dashboard ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """List datasets used in a dashboard.""" + asyncio.run(_dashboard_datasets_async(dashboard_id, alias)) + + +async def _dashboard_datasets_async(dashboard_id: str, alias: str) -> None: + """Async dashboard datasets implementation.""" + client = await _get_client(alias) + async with client: + service = DashboardService(client, get_settings()) + datasets = await service.get_dashboard_datasets(dashboard_id) + print_header("Dashboard Datasets", f"Dashboard: {dashboard_id}") + if not datasets: + print_info("No datasets found for this dashboard") + else: + for ds in datasets: + print_info(f" - {ds.name} ({ds.id})") + diff --git a/tcrm_toolkit/cli/commands/dataflows.py b/tcrm_toolkit/cli/commands/dataflows.py new file mode 100644 index 0000000..73e59c3 --- /dev/null +++ b/tcrm_toolkit/cli/commands/dataflows.py @@ -0,0 +1,173 @@ +"""Dataflow CLI commands.""" + +import asyncio +from pathlib import Path + +import typer + +from tcrm_toolkit.cli.ui import ( + console, + create_dataflow_job_table, + create_dataflow_table, + print_dataflow_details, + print_error, + print_info, + print_success, +) +from tcrm_toolkit.core import SalesforceClient, get_settings +from tcrm_toolkit.core.auth import SFCLIAuthService +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.core.services.dataflow_service import DataflowService + +app = typer.Typer(name="dataflows", help="Dataflow commands") + + +async def _get_client(alias: str = "default") -> SalesforceClient: + """Get authenticated SalesforceClient.""" + settings = get_settings() + crypto = create_crypto_manager() + auth_service = SFCLIAuthService(settings, crypto) + try: + token = await auth_service.get_access_token(alias, auto_refresh=False) + instance_url = await auth_service.get_instance_url(alias) + except Exception as e: + print_error(f"Authentication failed: {e}. Run 'tcrm auth login' first.") + raise typer.Exit(1) + return SalesforceClient(access_token=token, instance_url=instance_url, settings=settings) + + +@app.command("list") +def list_dataflows( + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """List all dataflows.""" + asyncio.run(_list_dataflows_async(alias)) + + +async def _list_dataflows_async(alias: str) -> None: + """Async list dataflows implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + dataflows = await service.list_dataflows() + table = create_dataflow_table(dataflows) + console.print(table) + + +@app.command("get") +def get_dataflow( + dataflow_id: str = typer.Argument(help="Dataflow ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Get dataflow details.""" + asyncio.run(_get_dataflow_async(dataflow_id, alias)) + + +async def _get_dataflow_async(dataflow_id: str, alias: str) -> None: + """Async get dataflow implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + dataflow = await service.get_dataflow(dataflow_id) + print_dataflow_details(dataflow) + + +@app.command("start") +def start_dataflow( + dataflow_id: str = typer.Argument(help="Dataflow ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Start a dataflow.""" + asyncio.run(_start_dataflow_async(dataflow_id, alias)) + + +async def _start_dataflow_async(dataflow_id: str, alias: str) -> None: + """Async start dataflow implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + job = await service.start_dataflow(dataflow_id) + print_success(f"Dataflow {dataflow_id} started. Job ID: {job.id} (Status: {job.status})") + + +@app.command("stop") +def stop_dataflow( + dataflow_id: str = typer.Argument(help="Dataflow ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Stop a running dataflow.""" + asyncio.run(_stop_dataflow_async(dataflow_id, alias)) + + +async def _stop_dataflow_async(dataflow_id: str, alias: str) -> None: + """Async stop dataflow implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + job = await service.stop_dataflow(dataflow_id) + print_success(f"Dataflow {dataflow_id} stop requested. Job ID: {job.id} (Status: {job.status})") + + +@app.command("jobs") +def list_dataflow_jobs( + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """List dataflow jobs.""" + asyncio.run(_list_dataflow_jobs_async(alias)) + + +async def _list_dataflow_jobs_async(alias: str) -> None: + """Async list dataflow jobs implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + jobs = await service.list_dataflow_jobs() + table = create_dataflow_job_table(jobs) + console.print(table) + + +@app.command("wait") +def wait_for_job( + job_id: str = typer.Argument(help="Dataflow job ID"), + poll_interval: int = typer.Option(10, "--interval", "-i", help="Poll interval in seconds"), + timeout: int = typer.Option(3600, "--timeout", "-t", help="Timeout in seconds"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Wait for a dataflow job to complete.""" + asyncio.run(_wait_for_job_async(job_id, poll_interval, timeout, alias)) + + +async def _wait_for_job_async(job_id: str, poll_interval: int, timeout: int, alias: str) -> None: + """Async wait for job implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + print_info(f"Waiting for job {job_id} to complete...") + job = await service.wait_for_dataflow_job(job_id, poll_interval=poll_interval, timeout=timeout) + if job.status == "Success": + print_success(f"Job {job_id} completed successfully (Status: {job.status})") + else: + print_error(f"Job {job_id} finished with status: {job.status}") + + +@app.command("backup") +def backup_dataflow( + dataflow_id: str = typer.Argument(help="Dataflow ID"), + output: Path = typer.Option(None, "--output", "-o", help="Output JSON file path"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Backup dataflow definition.""" + asyncio.run(_backup_dataflow_async(dataflow_id, output, alias)) + + +async def _backup_dataflow_async(dataflow_id: str, output: Path | None, alias: str) -> None: + """Async backup dataflow implementation.""" + if output is None: + output = Path(f"{dataflow_id}_backup.json") + + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + await service.backup_dataflow(dataflow_id, str(output)) + print_success(f"Dataflow backed up successfully to {output}") + diff --git a/tcrm_toolkit/cli/commands/datasets.py b/tcrm_toolkit/cli/commands/datasets.py new file mode 100644 index 0000000..e2851fe --- /dev/null +++ b/tcrm_toolkit/cli/commands/datasets.py @@ -0,0 +1,188 @@ +"""Dataset CLI commands.""" + +import asyncio +from pathlib import Path + +import typer + +from tcrm_toolkit.cli.ui import ( + console, + create_dataset_table, + print_dataset_details, + print_error, + print_extraction_progress, + print_header, + print_info, + print_success, + print_upload_progress, + prompt_confirm, +) +from tcrm_toolkit.core import SalesforceClient, get_settings +from tcrm_toolkit.core.auth import SFCLIAuthService +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.core.services.dataset_service import DatasetService + +app = typer.Typer(name="datasets", help="Dataset commands") + + +async def _get_client(alias: str = "default") -> SalesforceClient: + """Get authenticated SalesforceClient.""" + settings = get_settings() + crypto = create_crypto_manager() + auth_service = SFCLIAuthService(settings, crypto) + try: + token = await auth_service.get_access_token(alias, auto_refresh=False) + instance_url = await auth_service.get_instance_url(alias) + except Exception as e: + print_error(f"Authentication failed: {e}. Run 'tcrm auth login' first.") + raise typer.Exit(1) + return SalesforceClient(access_token=token, instance_url=instance_url, settings=settings) + + +@app.command("list") +def list_datasets( + page_size: int = typer.Option(50, "--page-size", "-n", help="Number of datasets per page"), + sort: str = typer.Option("Mru", "--sort", "-s", help="Sort order: Mru, Name, CreatedDate"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """List all datasets.""" + asyncio.run(_list_datasets_async(page_size, sort, alias)) + + +async def _list_datasets_async(page_size: int, sort: str, alias: str) -> None: + """Async list datasets implementation.""" + client = await _get_client(alias) + async with client: + service = DatasetService(client, get_settings()) + datasets = await service.list_datasets(page_size=page_size, sort=sort) + table = create_dataset_table(datasets) + console.print(table) + + +@app.command("get") +def get_dataset( + dataset_id: str = typer.Argument(help="Dataset ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Get dataset details.""" + asyncio.run(_get_dataset_async(dataset_id, alias)) + + +async def _get_dataset_async(dataset_id: str, alias: str) -> None: + """Async get dataset implementation.""" + client = await _get_client(alias) + async with client: + service = DatasetService(client, get_settings()) + dataset = await service.get_dataset(dataset_id) + print_dataset_details(dataset) + + +@app.command("extract") +def extract_dataset( + dataset_id: str = typer.Argument(help="Dataset ID"), + output: Path = typer.Option( + None, + "--output", + "-o", + help="Output CSV file path", + ), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Extract dataset to CSV.""" + asyncio.run(_extract_dataset_async(dataset_id, output, alias)) + + +async def _extract_dataset_async(dataset_id: str, output: Path | None, alias: str) -> None: + """Async extract dataset implementation.""" + if output is None: + output = Path(f"{dataset_id}.csv") + + print_header("Extract Dataset", f"Dataset: {dataset_id} -> {output}") + client = await _get_client(alias) + async with client: + service = DatasetService(client, get_settings()) + await service.extract_dataset( + dataset_id, + output, + progress_callback=lambda p: print_extraction_progress(p), + ) + print_success(f"Dataset extracted successfully to {output}") + + +@app.command("upload") +def upload_dataset( + dataset_id: str = typer.Argument(help="Dataset ID"), + file: Path = typer.Argument(help="CSV file to upload"), + operation: str = typer.Option( + "Overwrite", + "--operation", + help="Upload operation: Overwrite or Append", + ), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Upload CSV to dataset.""" + asyncio.run(_upload_dataset_async(dataset_id, file, operation, alias)) + + +async def _upload_dataset_async(dataset_id: str, file: Path, operation: str, alias: str) -> None: + """Async upload dataset implementation.""" + print_header("Upload Dataset", f"File: {file} -> Dataset: {dataset_id}") + client = await _get_client(alias) + async with client: + service = DatasetService(client, get_settings()) + await service.upload_csv( + dataset_id, + file, + operation=operation, + progress_callback=lambda p: print_upload_progress(p), + ) + print_success(f"CSV uploaded successfully to dataset {dataset_id}") + + +@app.command("delete") +def delete_dataset( + dataset_id: str = typer.Argument(help="Dataset ID"), + force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Delete a dataset.""" + asyncio.run(_delete_dataset_async(dataset_id, force, alias)) + + +async def _delete_dataset_async(dataset_id: str, force: bool, alias: str) -> None: + """Async delete dataset implementation.""" + if not force: + confirm = prompt_confirm(f"Delete dataset {dataset_id}? This cannot be undone.") + if not confirm: + print_info("Cancelled") + return + + client = await _get_client(alias) + async with client: + service = DatasetService(client, get_settings()) + await service.delete_dataset(dataset_id) + print_success(f"Dataset {dataset_id} deleted successfully") + + +@app.command("dependencies") +def dataset_dependencies( + dataset_id: str = typer.Argument(help="Dataset ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Show dataset dependencies (downstream dataflows/dashboards).""" + asyncio.run(_dataset_dependencies_async(dataset_id, alias)) + + +async def _dataset_dependencies_async(dataset_id: str, alias: str) -> None: + """Async dataset dependencies implementation.""" + client = await _get_client(alias) + async with client: + service = DatasetService(client, get_settings()) + deps = await service.get_dataset_dependencies(dataset_id) + print_header("Dataset Dependencies", f"Dataset: {dataset_id}") + if not deps: + print_info("No dependencies found") + else: + for dep in deps: + print_info(f" - {dep.get('type', 'Unknown')}: {dep.get('name', dep.get('id', 'N/A'))}") + diff --git a/tcrm_toolkit/cli/commands/jobs.py b/tcrm_toolkit/cli/commands/jobs.py new file mode 100644 index 0000000..6740f0e --- /dev/null +++ b/tcrm_toolkit/cli/commands/jobs.py @@ -0,0 +1,80 @@ +"""Data Manager Job CLI commands.""" + +import asyncio + +import typer + +from tcrm_toolkit.cli.ui import ( + console, + create_dataflow_job_table, + print_error, + print_header, + print_info, +) +from tcrm_toolkit.core import SalesforceClient, get_settings +from tcrm_toolkit.core.auth import SFCLIAuthService +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.core.services.dataflow_service import DataflowService + +app = typer.Typer(name="jobs", help="Data Manager job commands") + + +async def _get_client(alias: str = "default") -> SalesforceClient: + """Get authenticated SalesforceClient.""" + settings = get_settings() + crypto = create_crypto_manager() + auth_service = SFCLIAuthService(settings, crypto) + try: + token = await auth_service.get_access_token(alias, auto_refresh=False) + instance_url = await auth_service.get_instance_url(alias) + except Exception as e: + print_error(f"Authentication failed: {e}. Run 'tcrm auth login' first.") + raise typer.Exit(1) + return SalesforceClient(access_token=token, instance_url=instance_url, settings=settings) + + +@app.command("list") +def list_jobs( + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """List Data Manager jobs.""" + asyncio.run(_list_jobs_async(alias)) + + +async def _list_jobs_async(alias: str) -> None: + """Async list jobs implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + jobs = await service.list_dataflow_jobs() + table = create_dataflow_job_table(jobs) + console.print(table) + + +@app.command("get") +def get_job( + job_id: str = typer.Argument(help="Job ID"), + alias: str = typer.Option("default", "--alias", "-a", help="Org alias"), +) -> None: + """Get job details.""" + asyncio.run(_get_job_async(job_id, alias)) + + +async def _get_job_async(job_id: str, alias: str) -> None: + """Async get job implementation.""" + client = await _get_client(alias) + async with client: + service = DataflowService(client, get_settings()) + job = await service.get_dataflow_job_status(job_id) + if not job: + print_error(f"Job {job_id} not found") + raise typer.Exit(1) + print_header("Job Details", f"Job ID: {job_id}") + print_info(f"Dataflow: {job.dataflow_name} ({job.dataflow_id})") + print_info(f"Command: {job.command}") + print_info(f"Status: {job.status}") + if job.start_time: + print_info(f"Started: {job.start_time}") + if job.end_time: + print_info(f"Ended: {job.end_time}") + diff --git a/tcrm_toolkit/cli/main.py b/tcrm_toolkit/cli/main.py new file mode 100644 index 0000000..fa21916 --- /dev/null +++ b/tcrm_toolkit/cli/main.py @@ -0,0 +1,234 @@ +"""Main CLI entry point for TCRM Toolkit.""" + +import sys +from contextlib import asynccontextmanager + +import typer + +from tcrm_toolkit.cli.commands.auth import app as auth_app +from tcrm_toolkit.cli.commands.dashboards import app as dashboards_app +from tcrm_toolkit.cli.commands.dataflows import app as dataflows_app +from tcrm_toolkit.cli.commands.datasets import app as datasets_app +from tcrm_toolkit.cli.commands.jobs import app as jobs_app +from tcrm_toolkit.cli.ui import ( + console, + print_error, + print_header, + print_info, + print_success, + print_warning, +) +from tcrm_toolkit.core import get_settings +from tcrm_toolkit.core.crypto import CryptoManager +from tcrm_toolkit.core.services.auth_service import AuthService + +app = typer.Typer( + name="tcrm", + help="Salesforce TCRM Analytics Toolkit", + add_completion=False, + no_args_is_help=True, +) + +# Add subcommands +app.add_typer(auth_app, name="auth") +app.add_typer(datasets_app, name="datasets") +app.add_typer(dashboards_app, name="dashboards") +app.add_typer(dataflows_app, name="dataflows") +app.add_typer(jobs_app, name="jobs") + + +@asynccontextmanager +async def _get_authenticated_client(): + """Get an authenticated Salesforce client.""" + settings = get_settings() + auth_service = AuthService(settings, CryptoManager(settings.encryption_key)) + + # Try to get stored tokens - this is a placeholder + # In real implementation, we'd list available users and let them choose + print_info("Authentication required. Use 'tcrm auth login' first.") + yield None + await auth_service.close() + + +@app.command() +def interactive() -> None: + """Launch interactive TUI mode.""" + from tcrm_toolkit.interactive import TCRMApp + TCRMApp().run() + + +@app.callback(invoke_without_command=True) +def callback( + ctx: typer.Context, + verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"), + version: bool = typer.Option(False, "--version", help="Show version and exit"), + interactive_flag: bool = typer.Option(False, "--interactive", "-i", help="Launch interactive TUI"), +) -> None: + """TCRM Toolkit - Salesforce Tableau CRM Analytics Toolkit.""" + from tcrm_toolkit.core.logger import setup_logging + setup_logging() + + if version: + from tcrm_toolkit import __version__ + console.print(f"tcrm-toolkit version {__version__}") + raise typer.Exit() + + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + + if ctx.invoked_subcommand is None and not interactive_flag: + if sys.stdin.isatty() and sys.stdout.isatty(): + from tcrm_toolkit.interactive import TCRMApp + TCRMApp().run() + else: + ctx.invoke(app, ["--help"]) + + +@app.command() +def init() -> None: + """Initialize configuration file.""" + from tcrm_toolkit.core.config import generate_encryption_key, generate_jwt_secret + + print_header("TCRM Toolkit Initialization", "Generate configuration keys") + + encryption_key = generate_encryption_key() + jwt_secret = generate_jwt_secret() + + console.print("\n[bold]Add these to your .env file:[/bold]\n") + console.print(f"ENCRYPTION_KEY={encryption_key}") + console.print(f"JWT_SECRET_KEY={jwt_secret}") + console.print("\n[dim]Keep these secure! They are used to encrypt your credentials.[/dim]") + + +@app.command() +def config() -> None: + """Show current configuration (without secrets).""" + settings = get_settings() + + print_header("Configuration", "Current TCRM Toolkit settings") + + from tcrm_toolkit.cli.ui import Table + table = Table(show_header=False, box=None) + table.add_column("Setting", style="cyan") + table.add_column("Value", style="white") + + table.add_row("App Name", settings.app_name) + table.add_row("App Version", settings.app_version) + table.add_row("Debug Mode", str(settings.debug)) + table.add_row("Log Level", settings.log_level) + table.add_row("SF API Version", settings.sf_api_version) + table.add_row("SF Default Domain", settings.sf_default_domain) + table.add_row("Connected App Configured", "Yes" if settings.has_connected_app_credentials else "No") + table.add_row("Web OAuth Configured", "Yes" if settings.has_web_oauth_credentials else "No") + table.add_row("Device Flow Configured", "Yes" if settings.has_device_flow_credentials else "No") + + console.print(table) + + +@app.command() +def doctor() -> None: + """Run diagnostics to check setup.""" + import asyncio + import shutil + import subprocess + from pathlib import Path + + import keyring + + from tcrm_toolkit.interactive.safety import SafetyMonitor + + print_header("System Diagnostics", "Checking TCRM Toolkit setup and environment") + + settings = get_settings() + checks = [] + + # 1. Python version & dependencies + import sys + py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + checks.append(("Python version >= 3.11", sys.version_info >= (3, 11), f"Python {py_ver}")) + + # 2. SF CLI installation + sf_path = shutil.which("sf") or shutil.which("sfdx") + sf_installed = sf_path is not None + sf_version = "Not found" + if sf_installed: + try: + res = subprocess.run([sf_path, "--version"], capture_output=True, text=True, timeout=3) + sf_version = res.stdout.strip() + except Exception: + sf_version = "Installed (version check failed)" + checks.append(("Salesforce CLI (sf/sfdx)", sf_installed, sf_version)) + + # 3. Keyring access + keyring_accessible = True + keyring_detail = "Working" + try: + keyring.set_password("tcrm_test", "user", "test") + keyring.get_password("tcrm_test", "user") + keyring.delete_password("tcrm_test", "user") + except Exception as e: + keyring_accessible = False + keyring_detail = f"No backend/Headless ({e})" + checks.append(("Keyring access", True, keyring_accessible and "Working" or f"Warning: {keyring_detail}")) + + # 4. Config & data directory permissions + config_dir = Path.home() / ".tcrm" + dir_writable = True + try: + config_dir.mkdir(parents=True, exist_ok=True) + test_file = config_dir / ".test_write" + test_file.write_text("test") + test_file.unlink() + except Exception: + dir_writable = False + checks.append(("~/.tcrm directory writable", dir_writable, str(config_dir))) + + # 5. Connection Safety (VPN/Proxy check) + async def check_safety(): + monitor = SafetyMonitor(settings) + try: + res = await monitor.check_connection_safety(force=True) + return res.is_safe, f"Risk: {res.risk_level.value}" + except Exception as e: + return False, str(e) + finally: + await monitor.close() + + is_safe, safety_details = asyncio.run(check_safety()) + checks.append(("Connection Safety (VPN/Proxy)", is_safe, safety_details)) + + from tcrm_toolkit.cli.ui import Table + table = Table(title="Diagnostics Summary", show_header=True) + table.add_column("Check", style="cyan") + table.add_column("Status", style="white") + table.add_column("Details", style="dim") + + all_passed = True + for check_name, passed, details in checks: + status = "[green]✓ PASS[/green]" if passed else "[red]✗ FAIL[/red]" + if not passed: + all_passed = False + table.add_row(check_name, status, details) + + console.print(table) + + if all_passed: + print_success("All diagnostic checks passed!") + else: + print_warning("Some diagnostic checks failed. Review issues above.") + + +def main() -> None: + """Main entry point.""" + try: + app() + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted by user[/yellow]") + sys.exit(130) + except Exception as e: + print_error(f"Unexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tcrm_toolkit/cli/ui.py b/tcrm_toolkit/cli/ui.py new file mode 100644 index 0000000..82b5173 --- /dev/null +++ b/tcrm_toolkit/cli/ui.py @@ -0,0 +1,272 @@ +"""Rich UI components for TCRM Toolkit CLI.""" + +from collections.abc import Callable +from typing import Any + +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + TimeRemainingColumn, +) +from rich.prompt import Confirm, Prompt +from rich.table import Table +from rich.text import Text + +from tcrm_toolkit.core.models import ( + Dashboard, + Dataflow, + DataflowJob, + Dataset, + ExtractionProgress, + UploadProgress, +) + +console = Console() + + +def print_header(title: str, subtitle: str | None = None) -> None: + """Print a styled header.""" + text = Text(title, style="bold cyan") + if subtitle: + text.append(f"\n{subtitle}", style="dim") + console.print(Panel(text, border_style="cyan")) + + +def print_success(message: str) -> None: + """Print a success message.""" + console.print(f"[green]✓[/green] {message}") + + +def print_error(message: str) -> None: + """Print an error message.""" + console.print(f"[red]✗[/red] {message}") + + +def print_warning(message: str) -> None: + """Print a warning message.""" + console.print(f"[yellow]⚠[/yellow] {message}") + + +def print_info(message: str) -> None: + """Print an info message.""" + console.print(f"[blue]ℹ[/blue] {message}") + + +def create_dataset_table(datasets: list[Dataset]) -> Table: + """Create a formatted table for datasets.""" + table = Table(title="Datasets", show_header=True, header_style="bold magenta") + table.add_column("#", style="dim", width=4) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="green") + table.add_column("Label", style="white") + table.add_column("Rows", justify="right", style="yellow") + table.add_column("Status", style="magenta") + + for i, ds in enumerate(datasets, 1): + rows = str(ds.row_count) if ds.row_count is not None else "N/A" + table.add_row(str(i), ds.id, ds.name, ds.label, rows, ds.status) + + return table + + +def create_dashboard_table(dashboards: list[Dashboard]) -> Table: + """Create a formatted table for dashboards.""" + table = Table(title="Dashboards", show_header=True, header_style="bold magenta") + table.add_column("#", style="dim", width=4) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="green") + table.add_column("Label", style="white") + table.add_column("Folder", style="yellow") + + for i, db in enumerate(dashboards, 1): + folder = db.folder_name or "N/A" + table.add_row(str(i), db.id, db.name, db.label, folder) + + return table + + +def create_dataflow_table(dataflows: list[Dataflow]) -> Table: + """Create a formatted table for dataflows.""" + table = Table(title="Dataflows", show_header=True, header_style="bold magenta") + table.add_column("#", style="dim", width=4) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="green") + table.add_column("Label", style="white") + table.add_column("Status", style="yellow") + + for i, df in enumerate(dataflows, 1): + table.add_row(str(i), df.id, df.name, df.label, df.status) + + return table + + +def create_dataflow_job_table(jobs: list[DataflowJob]) -> Table: + """Create a formatted table for dataflow jobs.""" + table = Table(title="Dataflow Jobs", show_header=True, header_style="bold magenta") + table.add_column("#", style="dim", width=4) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Dataflow", style="green") + table.add_column("Command", style="yellow") + table.add_column("Status", style="magenta") + table.add_column("Start Time", style="dim") + table.add_column("End Time", style="dim") + + for i, job in enumerate(jobs, 1): + start = job.start_time.strftime("%Y-%m-%d %H:%M") if job.start_time else "N/A" + end = job.end_time.strftime("%Y-%m-%d %H:%M") if job.end_time else "N/A" + table.add_row(str(i), job.id, job.dataflow_name, job.command, job.status, start, end) + + return table + + +def create_progress_bar() -> Progress: + """Create a progress bar for long-running operations.""" + return Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + TimeRemainingColumn(), + console=console, + ) + + +async def run_with_progress( + coro, + description: str, + progress_callback: Callable | None = None, +) -> Any: + """Run an async coroutine with a progress bar.""" + with create_progress_bar() as progress: + task = progress.add_task(description, total=None) + + async def update_progress(progress_data): + if hasattr(progress_data, 'current_chunk') and hasattr(progress_data, 'total_chunks'): + progress.update(task, completed=progress_data.current_chunk, total=progress_data.total_chunks) + elif hasattr(progress_data, 'current_part') and hasattr(progress_data, 'total_parts'): + progress.update(task, completed=progress_data.current_part, total=progress_data.total_parts) + + if progress_callback: + # Wrap the callback to update progress bar + original_callback = progress_callback + + async def wrapped_callback(data): + await original_callback(data) + await update_progress(data) + + return await coro(progress_callback=wrapped_callback) + else: + return await coro() + + +def prompt_select(prompt_text: str, choices: list[str], default: str | None = None) -> str: + """Prompt user to select from a list of choices.""" + console.print(f"\n[bold]{prompt_text}[/bold]") + for i, choice in enumerate(choices, 1): + console.print(f" [cyan]{i}[/cyan]. {choice}") + + while True: + try: + selection = Prompt.ask( + "Enter your choice", + default=str(default) if default else None, + ) + idx = int(selection) - 1 + if 0 <= idx < len(choices): + return choices[idx] + print_error("Invalid selection. Please try again.") + except ValueError: + print_error("Please enter a number.") + + +def prompt_confirm(prompt_text: str, default: bool = False) -> bool: + """Prompt user for yes/no confirmation.""" + return Confirm.ask(prompt_text, default=default) + + +def prompt_text(prompt_text: str, default: str | None = None) -> str: + """Prompt user for text input.""" + return Prompt.ask(prompt_text, default=default) + + +def prompt_password(prompt_text: str) -> str: + """Prompt user for password input (hidden).""" + return Prompt.ask(prompt_text, password=True) + + +def print_dataset_details(dataset: Dataset) -> None: + """Print detailed dataset information.""" + table = Table(show_header=False, box=None) + table.add_column("Field", style="cyan") + table.add_column("Value", style="white") + + table.add_row("ID", dataset.id) + table.add_row("Name", dataset.name) + table.add_row("Label", dataset.label) + table.add_row("Description", dataset.description or "N/A") + table.add_row("Status", dataset.status) + table.add_row("Type", dataset.type) + table.add_row("Row Count", str(dataset.row_count) if dataset.row_count else "N/A") + table.add_row("Created", dataset.created_date.strftime("%Y-%m-%d %H:%M")) + table.add_row("Last Modified", dataset.last_modified_date.strftime("%Y-%m-%d %H:%M")) + + console.print(Panel(table, title=f"Dataset: {dataset.label}", border_style="green")) + + +def print_dashboard_details(dashboard: Dashboard) -> None: + """Print detailed dashboard information.""" + table = Table(show_header=False, box=None) + table.add_column("Field", style="cyan") + table.add_column("Value", style="white") + + table.add_row("ID", dashboard.id) + table.add_row("Name", dashboard.name) + table.add_row("Label", dashboard.label) + table.add_row("Description", dashboard.description or "N/A") + table.add_row("Folder", dashboard.folder_name or "N/A") + table.add_row("Created", dashboard.created_date.strftime("%Y-%m-%d %H:%M")) + table.add_row("Last Modified", dashboard.last_modified_date.strftime("%Y-%m-%d %H:%M")) + + console.print(Panel(table, title=f"Dashboard: {dashboard.label}", border_style="green")) + + +def print_dataflow_details(dataflow: Dataflow) -> None: + """Print detailed dataflow information.""" + table = Table(show_header=False, box=None) + table.add_column("Field", style="cyan") + table.add_column("Value", style="white") + + table.add_row("ID", dataflow.id) + table.add_row("Name", dataflow.name) + table.add_row("Label", dataflow.label) + table.add_row("Description", dataflow.description or "N/A") + table.add_row("Status", dataflow.status) + table.add_row("Created", dataflow.created_date.strftime("%Y-%m-%d %H:%M")) + table.add_row("Last Modified", dataflow.last_modified_date.strftime("%Y-%m-%d %H:%M")) + + console.print(Panel(table, title=f"Dataflow: {dataflow.label}", border_style="green")) + + +def print_extraction_progress(progress: ExtractionProgress) -> None: + """Print extraction progress.""" + pct = (progress.processed_rows / progress.total_rows * 100) if progress.total_rows > 0 else 0 + console.print( + f"[cyan]Chunk {progress.current_chunk}/{progress.total_chunks}[/cyan] | " + f"[green]{progress.processed_rows:,}/{progress.total_rows:,} rows[/green] " + f"([yellow]{pct:.1f}%[/yellow])" + ) + + +def print_upload_progress(progress: UploadProgress) -> None: + """Print upload progress.""" + pct = (progress.uploaded_rows / progress.total_rows * 100) if progress.total_rows > 0 else 0 + console.print( + f"[cyan]Part {progress.current_part}/{progress.total_parts}[/cyan] | " + f"[green]{progress.uploaded_rows:,}/{progress.total_rows:,} rows[/green] " + f"([yellow]{pct:.1f}%[/yellow])" + ) diff --git a/tcrm_toolkit/core/__init__.py b/tcrm_toolkit/core/__init__.py new file mode 100644 index 0000000..5bb1a36 --- /dev/null +++ b/tcrm_toolkit/core/__init__.py @@ -0,0 +1,65 @@ +"""Core SDK module for TCRM Toolkit.""" + +from tcrm_toolkit.core.auth import ( + SFCLIAuthError, + SFCLIAuthResult, + SFCLIAuthService, + SFCLIError, + SFCLIManager, + SFCLINotFoundError, + StoredToken, + TokenStore, +) +from tcrm_toolkit.core.client import SalesforceClient, create_client, create_client_from_sf_cli +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.crypto import CryptoManager, EncryptedData +from tcrm_toolkit.core.exceptions import ( + ConfigurationError, + CryptoError, + DashboardError, + DataflowError, + DatasetError, + OAuthError, + SalesforceAPIError, + SalesforceAuthError, + SalesforceNotFoundError, + SalesforceRateLimitError, + TCRMToolkitError, + TokenExpiredError, + TokenNotFoundError, + UploadError, + ValidationError, +) + +__all__ = [ + "Settings", + "get_settings", + "CryptoManager", + "EncryptedData", + "SalesforceClient", + "create_client", + "create_client_from_sf_cli", + "TCRMToolkitError", + "ConfigurationError", + "CryptoError", + "SalesforceAPIError", + "SalesforceAuthError", + "SalesforceRateLimitError", + "SalesforceNotFoundError", + "OAuthError", + "TokenExpiredError", + "TokenNotFoundError", + "ValidationError", + "DatasetError", + "DashboardError", + "DataflowError", + "UploadError", + "SFCLIAuthService", + "SFCLIAuthError", + "TokenStore", + "StoredToken", + "SFCLIManager", + "SFCLIAuthResult", + "SFCLIError", + "SFCLINotFoundError", +] diff --git a/tcrm_toolkit/core/auth/__init__.py b/tcrm_toolkit/core/auth/__init__.py new file mode 100644 index 0000000..d28ca03 --- /dev/null +++ b/tcrm_toolkit/core/auth/__init__.py @@ -0,0 +1,16 @@ +"""Authentication module for TCRM Toolkit.""" + +from tcrm_toolkit.core.auth.sf_cli_auth import SFCLIAuthError, SFCLIAuthService +from tcrm_toolkit.core.auth.token_store import StoredToken, TokenStore +from tcrm_toolkit.core.sf_cli import SFCLIAuthResult, SFCLIError, SFCLIManager, SFCLINotFoundError + +__all__ = [ + "SFCLIAuthService", + "SFCLIAuthError", + "TokenStore", + "StoredToken", + "SFCLIManager", + "SFCLIAuthResult", + "SFCLIError", + "SFCLINotFoundError", +] diff --git a/tcrm_toolkit/core/auth/sf_cli_auth.py b/tcrm_toolkit/core/auth/sf_cli_auth.py new file mode 100644 index 0000000..bd320ac --- /dev/null +++ b/tcrm_toolkit/core/auth/sf_cli_auth.py @@ -0,0 +1,278 @@ +"""SF CLI-based authentication service.""" + +from typing import Any + +import structlog + +from tcrm_toolkit.core.auth.token_store import StoredToken, TokenStore +from tcrm_toolkit.core.config import Settings +from tcrm_toolkit.core.crypto import CryptoManager +from tcrm_toolkit.core.sf_cli import SFCLIError, SFCLIManager, SFCLINotFoundError + +logger = structlog.get_logger(__name__) + + +class SFCLIAuthError(Exception): + """SF CLI authentication error.""" + pass + + +class SFCLIAuthService: + """High-level SF CLI authentication service.""" + + def __init__( + self, + settings: Settings, + crypto_manager: CryptoManager, + sf_cli_manager: SFCLIManager | None = None, + ): + """ + Initialize SF CLI auth service. + + Args: + settings: Application settings + crypto_manager: CryptoManager for token encryption + sf_cli_manager: Optional SFCLIManager instance (created if not provided) + """ + self.settings = settings + self.crypto = crypto_manager + self.sf_cli = sf_cli_manager or SFCLIManager() + self.token_store = TokenStore(crypto_manager) + + async def login( + self, + alias: str = "default", + instance_url: str | None = None, + timeout: int = 300, + ) -> str: + """ + Run full web login flow via SF CLI. + + Args: + alias: Org alias to use + instance_url: Optional custom instance URL + timeout: Timeout in seconds for login flow + + Returns: + Access token + + Raises: + SFCLIAuthError: If login fails + """ + if not self.sf_cli.is_available(): + raise SFCLIAuthError( + "SF CLI not found. Install from https://developer.salesforce.com/tools/sfdxcli" + ) + + logger.info("starting_sf_cli_login", alias=alias) + + try: + # Run SF CLI web login + auth_result = await self.sf_cli.login_web( + alias=alias, + instance_url=instance_url, + timeout=timeout, + ) + + # Store token + stored_token = StoredToken( + access_token=auth_result.access_token, + instance_url=auth_result.instance_url, + refresh_token=auth_result.refresh_token, + expires_at=auth_result.expires_at.isoformat() if auth_result.expires_at else None, + alias=auth_result.alias, + username=auth_result.username, + ) + await self.token_store.save_token(stored_token) + + logger.info("sf_cli_login_success", alias=alias, username=auth_result.username) + return auth_result.access_token + + except SFCLINotFoundError as e: + raise SFCLIAuthError(str(e)) from e + except SFCLIError as e: + raise SFCLIAuthError(f"SF CLI login failed: {e}") from e + except Exception as e: + raise SFCLIAuthError(f"Unexpected error during login: {e}") from e + + async def login_device( + self, + alias: str = "default", + instance_url: str | None = None, + timeout: int = 300, + ) -> str: + """ + Run device login flow via SF CLI (for headless environments). + + Args: + alias: Org alias to use + instance_url: Optional custom instance URL + timeout: Timeout in seconds for login flow + + Returns: + Access token + + Raises: + SFCLIAuthError: If login fails + """ + if not self.sf_cli.is_available(): + raise SFCLIAuthError( + "SF CLI not found. Install from https://developer.salesforce.com/tools/sfdxcli" + ) + + logger.info("starting_sf_cli_device_login", alias=alias) + + try: + auth_result = await self.sf_cli.login_device( + alias=alias, + instance_url=instance_url, + timeout=timeout, + ) + + stored_token = StoredToken( + access_token=auth_result.access_token, + instance_url=auth_result.instance_url, + refresh_token=auth_result.refresh_token, + expires_at=auth_result.expires_at.isoformat() if auth_result.expires_at else None, + alias=auth_result.alias, + username=auth_result.username, + ) + await self.token_store.save_token(stored_token) + + logger.info("sf_cli_device_login_success", alias=alias, username=auth_result.username) + return auth_result.access_token + + except SFCLINotFoundError as e: + raise SFCLIAuthError(str(e)) from e + except SFCLIError as e: + raise SFCLIAuthError(f"SF CLI device login failed: {e}") from e + except Exception as e: + raise SFCLIAuthError(f"Unexpected error during device login: {e}") from e + + async def get_access_token( + self, + alias: str = "default", + auto_refresh: bool = False, + ) -> str: + """ + Get valid access token. + + Args: + alias: Org alias + auto_refresh: Whether to attempt auto-refresh + + Returns: + Valid access token + + Raises: + SFCLIAuthError: If no valid token available + """ + # Try to get valid token from store + token = await self.token_store.get_valid_token(alias, self.sf_cli, auto_refresh) + + if token and not token.is_expired(): + return token.access_token + + raise SFCLIAuthError( + f"No valid token for alias '{alias}'. Run 'tcrm auth login' first." + ) + + async def get_instance_url(self, alias: str = "default") -> str: + """ + Get instance URL for alias. + + Args: + alias: Org alias + + Returns: + Instance URL + + Raises: + SFCLIAuthError: If no token available + """ + token = await self.token_store.load_token(alias) + if not token: + raise SFCLIAuthError(f"No token for alias '{alias}'. Run 'tcrm auth login' first.") + return token.instance_url + + async def get_username(self, alias: str = "default") -> str | None: + """ + Get username for alias. + + Args: + alias: Org alias + + Returns: + Username if available + """ + token = await self.token_store.load_token(alias) + return token.username if token else None + + async def logout(self, alias: str = "default") -> bool: + """ + Logout and remove stored auth. + + Args: + alias: Org alias + + Returns: + True if logged out, False if no token was stored + """ + # Remove from SF CLI + try: + await self.sf_cli.logout(alias) + except Exception as e: + logger.warning("sf_cli_logout_failed", alias=alias, error=str(e)) + + # Remove from token store + return await self.token_store.delete_token(alias) + + async def status(self, alias: str = "default") -> dict[str, Any]: + """ + Get authentication status. + + Args: + alias: Org alias + + Returns: + Status dictionary + """ + token = await self.token_store.load_token(alias) + + if not token: + return { + "authenticated": False, + "alias": alias, + "message": "Not authenticated. Run 'tcrm auth login'.", + } + + is_expired = token.is_expired() + sf_cli_available = self.sf_cli.is_available() + + return { + "authenticated": True, + "alias": token.alias, + "username": token.username, + "instance_url": token.instance_url, + "token_expired": is_expired, + "expires_at": token.expires_at, + "created_at": token.created_at, + "updated_at": token.updated_at, + "sf_cli_available": sf_cli_available, + "message": "Token expired" if is_expired else "Authenticated", + } + + async def list_orgs(self) -> list[dict[str, Any]]: + """List all authorized orgs from SF CLI.""" + if not self.sf_cli.is_available(): + return [] + + try: + return self.sf_cli.list_orgs() + except Exception as e: + logger.error("list_orgs_failed", error=str(e)) + return [] + + async def close(self) -> None: + """Cleanup resources.""" + pass diff --git a/tcrm_toolkit/core/auth/token_store.py b/tcrm_toolkit/core/auth/token_store.py new file mode 100644 index 0000000..54ca45e --- /dev/null +++ b/tcrm_toolkit/core/auth/token_store.py @@ -0,0 +1,190 @@ +"""Secure token storage with encryption and keyring integration.""" + +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +import structlog + +from tcrm_toolkit.core.crypto import CryptoManager, EncryptedData, SafeKeyring + +logger = structlog.get_logger(__name__) + + +@dataclass +class StoredToken: + """Stored token data with metadata.""" + access_token: str + instance_url: str + refresh_token: str | None = None + expires_at: str | None = None # ISO format string + alias: str = "default" + username: str | None = None + created_at: str = "" # ISO format string + updated_at: str = "" # ISO format string + + def __post_init__(self): + now = datetime.now(timezone.utc).isoformat() + if not self.created_at: + self.created_at = now + if not self.updated_at: + self.updated_at = now + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "StoredToken": + """Create from dictionary.""" + return cls(**data) + + def is_expired(self, buffer_seconds: int = 60) -> bool: + """Check if token is expired (with buffer).""" + if not self.expires_at: + return True + try: + expires = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00")) + return datetime.now(timezone.utc) >= (expires - timedelta(seconds=buffer_seconds)) + except (ValueError, TypeError): + return True + + def update_timestamp(self) -> None: + """Update the updated_at timestamp.""" + self.updated_at = datetime.now(timezone.utc).isoformat() + + +class TokenStore: + """Secure token storage with encryption and keyring integration.""" + + KEYRING_SERVICE = "tcrm-toolkit-sfcli" + + def __init__(self, crypto_manager: CryptoManager): + """ + Initialize token store. + + Args: + crypto_manager: CryptoManager instance for encryption + """ + self.crypto = crypto_manager + + def _get_keyring_key(self, alias: str) -> str: + """Get keyring key for alias.""" + return f"sfcli_token:{alias}" + + async def save_token(self, token: StoredToken) -> None: + """ + Encrypt and store token in keyring. + + Args: + token: StoredToken to save + """ + token.update_timestamp() + + # Serialize to JSON + json_data = json.dumps(token.to_dict()) + + # Encrypt + encrypted = self.crypto.encrypt(json_data) + + # Store in keyring + SafeKeyring.set_password( + self.KEYRING_SERVICE, + self._get_keyring_key(token.alias), + encrypted.to_json(), + ) + + logger.info("token_saved", alias=token.alias, username=token.username) + + async def load_token(self, alias: str = "default") -> StoredToken | None: + """ + Load and decrypt token from keyring. + + Args: + alias: Org alias + + Returns: + StoredToken if found, None otherwise + """ + stored = SafeKeyring.get_password(self.KEYRING_SERVICE, self._get_keyring_key(alias)) + if not stored: + return None + + try: + encrypted = EncryptedData.from_json(stored) + json_data = self.crypto.decrypt(encrypted) + data = json.loads(json_data) + return StoredToken.from_dict(data) + except Exception as e: + logger.error("token_load_failed", alias=alias, error=str(e)) + # If decryption fails, remove corrupted entry + await self.delete_token(alias) + return None + + async def delete_token(self, alias: str = "default") -> bool: + """ + Delete stored token from keyring. + + Args: + alias: Org alias + + Returns: + True if deleted, False if not found + """ + success = SafeKeyring.delete_password(self.KEYRING_SERVICE, self._get_keyring_key(alias)) + if success: + logger.info("token_deleted", alias=alias) + return success + + async def get_valid_token( + self, + alias: str, + sf_cli_manager: "SFCLIManager", + auto_refresh: bool = True, + ) -> StoredToken | None: + """ + Get valid token, auto-refresh if needed. + + Args: + alias: Org alias + sf_cli_manager: SFCLIManager instance for refresh + auto_refresh: Whether to attempt auto-refresh + + Returns: + Valid StoredToken or None if not available + """ + token = await self.load_token(alias) + if not token: + return None + + if not token.is_expired(): + return token + + logger.info("token_expired_attempting_refresh", alias=alias) + + if not auto_refresh: + return None + + # Try to refresh via SF CLI + try: + auth_result = await sf_cli_manager.refresh_token(alias) + new_token = StoredToken( + access_token=auth_result.access_token, + instance_url=auth_result.instance_url, + refresh_token=auth_result.refresh_token, + expires_at=auth_result.expires_at.isoformat() if auth_result.expires_at else None, + alias=auth_result.alias, + username=auth_result.username, + ) + await self.save_token(new_token) + return new_token + except Exception as e: + logger.error("token_refresh_failed", alias=alias, error=str(e)) + return None + + async def list_aliases(self) -> list[str]: + """List all stored aliases (limited by keyring capabilities).""" + # Note: keyring doesn't have a direct list method + # This is a placeholder for future implementation + return [] diff --git a/tcrm_toolkit/core/client.py b/tcrm_toolkit/core/client.py new file mode 100644 index 0000000..38a2292 --- /dev/null +++ b/tcrm_toolkit/core/client.py @@ -0,0 +1,464 @@ +"""Async HTTP client for Salesforce API with retry logic and circuit breaker.""" + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any, Optional + +import httpx +import structlog +from tenacity import ( + AsyncRetrying, + after_log, + before_sleep_log, + retry_if_exception_type, + stop_after_attempt, + wait_exponential_jitter, +) + +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.exceptions import ( + SalesforceAPIError, + SalesforceAuthError, + SalesforceNotFoundError, + SalesforceRateLimitError, +) + +logger = structlog.get_logger(__name__) + + +class SalesforceClient: + """Async HTTP client for Salesforce REST API with resilience patterns.""" + + def __init__( + self, + access_token: str, + instance_url: str, + settings: Settings | None = None, + ): + """Initialize the client with authentication and configuration. + + Args: + access_token: OAuth access token for authentication + instance_url: Salesforce instance URL (e.g., https://na100.salesforce.com) + settings: Optional settings override + """ + self.access_token = access_token + self.instance_url = instance_url.rstrip("/") + self.settings = settings or get_settings() + self._client: httpx.AsyncClient | None = None + self._retry_client: AsyncRetrying | None = None + + @property + def base_url(self) -> str: + """Get the base API URL for this instance.""" + return f"{self.instance_url}/services/data/{self.settings.sf_api_version}" + + @property + def wave_base_url(self) -> str: + """Get the Wave/Analytics API base URL.""" + return f"{self.base_url}/wave" + + @property + def client(self) -> httpx.AsyncClient: + """Get or create the underlying HTTP client.""" + if self._client is None: + self._client = httpx.AsyncClient( + timeout=httpx.Timeout( + connect=10.0, + read=60.0, + write=30.0, + pool=10.0, + ), + limits=httpx.Limits( + max_connections=10, + max_keepalive_connections=5, + keepalive_expiry=30.0, + ), + headers={ + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": f"{self.settings.app_name}/{self.settings.app_version}", + }, + follow_redirects=True, + ) + return self._client + + @property + def retry_client(self) -> AsyncRetrying: + """Get or create the retry client with configured policies.""" + if self._retry_client is None: + self._retry_client = AsyncRetrying( + wait=wait_exponential_jitter(initial=1, max=30), + stop=stop_after_attempt(3), + retry=retry_if_exception_type(( + httpx.TimeoutException, + httpx.NetworkError, + httpx.RemoteProtocolError, + SalesforceRateLimitError, + )), + before_sleep=before_sleep_log(logger, logging.WARNING), + after=after_log(logger, logging.INFO), + reraise=True, + ) + return self._retry_client + + async def close(self) -> None: + """Close the underlying HTTP client.""" + if self._client: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> "SalesforceClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + def _build_url(self, path: str) -> str: + """Build full URL from path.""" + if path.startswith("http"): + return path + if path.startswith("/"): + return f"{self.base_url}{path}" + return f"{self.base_url}/{path}" + + def _handle_response(self, response: httpx.Response) -> httpx.Response: + """Handle response and raise appropriate exceptions.""" + if response.status_code == 401: + raise SalesforceAuthError("Authentication failed or token expired") + elif response.status_code == 403: + raise SalesforceAPIError("Access forbidden", status_code=403) + elif response.status_code == 404: + raise SalesforceNotFoundError("Resource not found") + elif response.status_code == 429: + # Rate limited - extract retry-after header if present + retry_after = response.headers.get("Retry-After", "60") + raise SalesforceRateLimitError( + "Rate limit exceeded", + retry_after=int(retry_after) if retry_after.isdigit() else 60, + ) + elif response.status_code >= 500: + raise SalesforceAPIError( + f"Server error: {response.status_code}", + status_code=response.status_code, + ) + elif response.status_code >= 400: + # Try to parse error details from response + try: + error_data = response.json() + # Salesforce returns errors as a list + if isinstance(error_data, list) and error_data: + error = error_data[0] + error_msg = error.get("message", "Unknown error") + error_code = error.get("errorCode", "UNKNOWN_ERROR") + else: + error_msg = error_data.get("message", "Unknown error") + error_code = error_data.get("errorCode", "UNKNOWN_ERROR") + raise SalesforceAPIError( + f"{error_code}: {error_msg}", + status_code=response.status_code, + error_code=error_code, + ) + except SalesforceAPIError: + # Re-raise SalesforceAPIError (e.g., from the raise above) + raise + except Exception: + raise SalesforceAPIError( + f"Request failed: {response.status_code}", + status_code=response.status_code, + ) + return response + + async def _request( + self, + method: str, + path: str, + **kwargs: Any, + ) -> httpx.Response: + """Make an HTTP request with retry logic.""" + url = self._build_url(path) + + async def _make_request() -> httpx.Response: + response = await self.client.request(method, url, **kwargs) + return self._handle_response(response) + + return await self.retry_client(_make_request) + + # Convenience methods + async def get(self, path: str, params: dict | None = None, **kwargs: Any) -> httpx.Response: + """GET request.""" + return await self._request("GET", path, params=params, **kwargs) + + async def post( + self, + path: str, + json: dict | None = None, + data: Any = None, + **kwargs: Any, + ) -> httpx.Response: + """POST request.""" + return await self._request("POST", path, json=json, data=data, **kwargs) + + async def patch(self, path: str, json: dict | None = None, **kwargs: Any) -> httpx.Response: + """PATCH request.""" + return await self._request("PATCH", path, json=json, **kwargs) + + async def delete(self, path: str, **kwargs: Any) -> httpx.Response: + """DELETE request.""" + return await self._request("DELETE", path, **kwargs) + + # Salesforce-specific API methods + async def query(self, soql: str) -> dict[str, Any]: + """Execute a SOQL query.""" + response = await self.get("/query", params={"q": soql}) + return response.json() + + async def query_all(self, soql: str) -> dict[str, Any]: + """Execute a SOQL query including deleted/archived records.""" + response = await self.get("/queryAll", params={"q": soql}) + return response.json() + + async def saql_query(self, query: str) -> dict[str, Any]: + """Execute a SAQL query against Wave/Analytics.""" + payload = {"query": query, "queryLanguage": "SAQL"} + response = await self.post(f"{self.wave_base_url}/query", json=payload) + return response.json() + + # Dataset methods + async def list_datasets( + self, + page_size: int = 50, + sort: str = "Mru", + page_token: str | None = None, + ) -> dict[str, Any]: + """List datasets with pagination.""" + params = {"pageSize": page_size, "sort": sort} + if page_token: + params["pageToken"] = page_token + response = await self.get(f"{self.wave_base_url}/datasets", params=params) + return response.json() + + async def get_dataset(self, dataset_id: str) -> dict[str, Any]: + """Get dataset details by ID.""" + response = await self.get(f"{self.wave_base_url}/datasets/{dataset_id}") + return response.json() + + async def get_dataset_version(self, dataset_id: str, version_id: str) -> dict[str, Any]: + """Get specific dataset version.""" + response = await self.get(f"{self.wave_base_url}/datasets/{dataset_id}/versions/{version_id}") + return response.json() + + async def get_dataset_xmd(self, dataset_id: str, version_id: str) -> dict[str, Any]: + """Get dataset XMD (Extended Metadata).""" + response = await self.get( + f"{self.wave_base_url}/datasets/{dataset_id}/versions/{version_id}/xmds/main" + ) + return response.json() + + async def delete_dataset(self, dataset_id: str) -> httpx.Response: + """Delete a dataset.""" + return await self.delete(f"{self.wave_base_url}/datasets/{dataset_id}") + + async def get_dataset_dependencies(self, dataset_id: str) -> dict[str, Any]: + """Get dataset dependencies (downstream dataflows/dashboards).""" + response = await self.get(f"{self.wave_base_url}/dependencies/{dataset_id}") + return response.json() + + # Dashboard methods + async def list_dashboards( + self, + page_size: int = 50, + sort: str = "Mru", + page_token: str | None = None, + ) -> dict[str, Any]: + """List dashboards with pagination.""" + params = {"pageSize": page_size, "sort": sort} + if page_token: + params["pageToken"] = page_token + response = await self.get(f"{self.wave_base_url}/dashboards", params=params) + return response.json() + + async def get_dashboard(self, dashboard_id: str) -> dict[str, Any]: + """Get dashboard details by ID.""" + response = await self.get(f"{self.wave_base_url}/dashboards/{dashboard_id}") + return response.json() + + async def get_dashboard_datasets(self, dashboard_id: str) -> dict[str, Any]: + """Get datasets used in a dashboard.""" + response = await self.get(f"{self.wave_base_url}/dashboards/{dashboard_id}/datasets") + return response.json() + + async def delete_dashboard(self, dashboard_id: str) -> httpx.Response: + """Delete a dashboard.""" + return await self.delete(f"{self.wave_base_url}/dashboards/{dashboard_id}") + + # Dataflow methods + async def list_dataflows(self) -> dict[str, Any]: + """List all dataflows.""" + response = await self.get(f"{self.wave_base_url}/dataflows") + return response.json() + + async def get_dataflow(self, dataflow_id: str) -> dict[str, Any]: + """Get dataflow details by ID.""" + response = await self.get(f"{self.wave_base_url}/dataflows/{dataflow_id}") + return response.json() + + async def start_dataflow(self, dataflow_id: str) -> dict[str, Any]: + """Start a dataflow execution.""" + payload = {"dataflowId": dataflow_id, "command": "start"} + response = await self.post(f"{self.wave_base_url}/dataflowjobs", json=payload) + return response.json() + + async def stop_dataflow(self, dataflow_id: str) -> dict[str, Any]: + """Stop a running dataflow.""" + payload = {"dataflowId": dataflow_id, "command": "stop"} + response = await self.post(f"{self.wave_base_url}/dataflowjobs", json=payload) + return response.json() + + async def list_dataflow_jobs(self) -> dict[str, Any]: + """List dataflow jobs.""" + response = await self.get(f"{self.wave_base_url}/dataflowjobs") + return response.json() + + # Data Manager / External Data methods + async def create_insights_external_data( + self, + edgemart_alias: str, + metadata_json: str, + operation: str = "Overwrite", + ) -> dict[str, Any]: + """Create an InsightsExternalData job for CSV upload.""" + payload = { + "Format": "Csv", + "EdgemartAlias": edgemart_alias, + "Operation": operation, + "Action": "None", + "MetadataJson": metadata_json, + } + response = await self.post("/sobjects/InsightsExternalData", json=payload) + return response.json() + + async def upload_insights_external_data_part( + self, + external_data_id: str, + part_number: int, + data_file_base64: str, + ) -> dict[str, Any]: + """Upload a part of the CSV data.""" + payload = { + "DataFile": data_file_base64, + "InsightsExternalDataId": external_data_id, + "PartNumber": part_number, + } + response = await self.post("/sobjects/InsightsExternalDataPart", json=payload) + return response.json() + + async def process_insights_external_data(self, external_data_id: str) -> dict[str, Any]: + """Process the uploaded data (trigger Data Manager job).""" + payload = {"Action": "Process"} + response = await self.patch(f"/sobjects/InsightsExternalData/{external_data_id}", json=payload) + return response.json() + + # Limits + async def get_limits(self) -> dict[str, Any]: + """Get API limits.""" + response = await self.get("/limits") + return response.json() + + # Streaming/chunked upload for large files + async def upload_large_file_streaming( + self, + edgemart_alias: str, + metadata_json: str, + file_path: str, + chunk_size: int = 50000, + operation: str = "Overwrite", + ) -> AsyncIterator[dict[str, Any]]: + """Stream upload a large CSV file in chunks. + + Yields progress updates for each chunk. + """ + import pandas as pd + + # Create the external data job + job = await self.create_insights_external_data( + edgemart_alias=edgemart_alias, + metadata_json=metadata_json, + operation=operation, + ) + external_data_id = job["id"] + yield {"status": "created", "job_id": external_data_id} + + # Read and upload in chunks + part_number = 1 + for chunk in pd.read_csv(file_path, chunksize=chunk_size): + csv_data = chunk.to_csv(index=False) + import base64 + data_file_base64 = base64.b64encode(csv_data.encode()).decode() + + await self.upload_insights_external_data_part( + external_data_id=external_data_id, + part_number=part_number, + data_file_base64=data_file_base64, + ) + yield {"status": "uploaded_part", "part": part_number, "rows": len(chunk)} + part_number += 1 + + # Process the data + result = await self.process_insights_external_data(external_data_id) + yield {"status": "processing", "result": result} + + +@asynccontextmanager +async def create_client( + access_token: str, + instance_url: str, + settings: Settings | None = None, +) -> AsyncIterator[SalesforceClient]: + """Context manager for creating and closing a SalesforceClient.""" + client = SalesforceClient(access_token, instance_url, settings) + try: + yield client + finally: + await client.close() + + +@asynccontextmanager +async def create_client_from_sf_cli( + alias: str = "default", + settings: Settings | None = None, + crypto_manager: Optional["CryptoManager"] = None, +) -> AsyncIterator[SalesforceClient]: + """ + Context manager for creating a SalesforceClient using SF CLI authentication. + + Args: + alias: SF CLI org alias + settings: Optional settings override + crypto_manager: Optional CryptoManager for token encryption + + Yields: + Authenticated SalesforceClient + + Example: + async with create_client_from_sf_cli("myorg") as client: + datasets = await client.list_datasets() + """ + from tcrm_toolkit.core.auth.sf_cli_auth import SFCLIAuthService + from tcrm_toolkit.core.config import get_settings + from tcrm_toolkit.core.crypto import create_crypto_manager + + settings = settings or get_settings() + crypto = crypto_manager or create_crypto_manager() + auth_service = SFCLIAuthService(settings, crypto) + + access_token = await auth_service.get_access_token(alias) + instance_url = await auth_service.get_instance_url(alias) + + client = SalesforceClient(access_token, instance_url, settings) + try: + yield client + finally: + await client.close() diff --git a/tcrm_toolkit/core/config.py b/tcrm_toolkit/core/config.py new file mode 100644 index 0000000..033d699 --- /dev/null +++ b/tcrm_toolkit/core/config.py @@ -0,0 +1,134 @@ +"""Configuration management using Pydantic Settings.""" + +import base64 +import os +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from tcrm_toolkit.core.platform import get_cache_dir, get_config_dir, get_data_dir + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + populate_by_name=True, + ) + + # Application Settings + app_name: str = "tcrm-toolkit" + app_version: str = "0.1.0" + debug: bool = False + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + + # Cross-platform directories + config_dir: Path = Field(default_factory=get_config_dir) + data_dir: Path = Field(default_factory=get_data_dir) + cache_dir: Path = Field(default_factory=get_cache_dir) + + # Salesforce API Settings + sf_api_version: str = Field(default="v60.0", alias="SF_API_VERSION") + sf_default_domain: str = Field(default="login.salesforce.com", alias="SF_DEFAULT_DOMAIN") + + # Encryption Settings + encryption_key: str = Field(alias="ENCRYPTION_KEY") + + # JWT Settings + jwt_secret_key: str = Field(alias="JWT_SECRET_KEY") + jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM") + access_token_expire_minutes: int = Field(default=30, alias="ACCESS_TOKEN_EXPIRE_MINUTES") + refresh_token_expire_days: int = Field(default=30, alias="REFRESH_TOKEN_EXPIRE_DAYS") + + # Connected App Credentials (JWT Bearer flow) + sf_connected_app_client_id: str | None = Field(default=None, alias="SF_CONNECTED_APP_CLIENT_ID") + sf_connected_app_client_secret: str | None = Field(default=None, alias="SF_CONNECTED_APP_CLIENT_SECRET") + sf_connected_app_username: str | None = Field(default=None, alias="SF_CONNECTED_APP_USERNAME") + + # Web OAuth Settings (PKCE flow) + sf_web_oauth_client_id: str | None = Field(default=None, alias="SF_WEB_OAUTH_CLIENT_ID") + sf_web_oauth_client_secret: str | None = Field(default=None, alias="SF_WEB_OAUTH_CLIENT_SECRET") + sf_web_oauth_redirect_uri: str = Field(default="http://localhost:8080/callback", alias="SF_WEB_OAUTH_REDIRECT_URI") + + # Device Flow Settings + sf_device_flow_client_id: str | None = Field(default=None, alias="SF_DEVICE_FLOW_CLIENT_ID") + + # Safety Monitor Settings + safety_check_interval: int = Field(default=300, alias="SAFETY_CHECK_INTERVAL") + safety_block_on_critical: bool = Field(default=True, alias="SAFETY_BLOCK_ON_CRITICAL") + safety_allowlist_ips: list[str] = Field(default_factory=list, alias="SAFETY_ALLOWLIST_IPS") + + @field_validator("encryption_key") + @classmethod + def validate_encryption_key(cls, v: str) -> str: + """Validate that encryption key is a valid base64-encoded 32-byte key.""" + try: + decoded = base64.urlsafe_b64decode(v + "=" * (-len(v) % 4)) + if len(decoded) != 32: + raise ValueError("Encryption key must decode to exactly 32 bytes") + except Exception as e: + raise ValueError(f"Invalid encryption key: {e}") + return v + + @field_validator("jwt_secret_key") + @classmethod + def validate_jwt_secret(cls, v: str) -> str: + """Validate JWT secret key length.""" + if len(v) < 32: + raise ValueError("JWT secret key must be at least 32 characters") + return v + + @property + def sf_base_url(self) -> str: + """Get the base Salesforce API URL.""" + return f"https://{self.sf_default_domain}/services/data/{self.sf_api_version}" + + @property + def wave_base_url(self) -> str: + """Get the Wave/Analytics API base URL.""" + return f"{self.sf_base_url}/wave" + + @property + def has_connected_app_credentials(self) -> bool: + """Check if Connected App credentials are configured.""" + return all([ + self.sf_connected_app_client_id, + self.sf_connected_app_client_secret, + self.sf_connected_app_username, + ]) + + @property + def has_web_oauth_credentials(self) -> bool: + """Check if Web OAuth credentials are configured.""" + return all([ + self.sf_web_oauth_client_id, + self.sf_web_oauth_client_secret, + ]) + + @property + def has_device_flow_credentials(self) -> bool: + """Check if Device Flow credentials are configured.""" + return self.sf_device_flow_client_id is not None + + +@lru_cache +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() + + +def generate_encryption_key() -> str: + """Generate a new secure encryption key.""" + return base64.urlsafe_b64encode(os.urandom(32)).decode() + + +def generate_jwt_secret() -> str: + """Generate a new JWT secret key.""" + return base64.urlsafe_b64encode(os.urandom(32)).decode() diff --git a/tcrm_toolkit/core/crypto.py b/tcrm_toolkit/core/crypto.py new file mode 100644 index 0000000..a3e5f80 --- /dev/null +++ b/tcrm_toolkit/core/crypto.py @@ -0,0 +1,204 @@ +"""Cryptography utilities with dynamic salt and keyring integration.""" + +import base64 +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import keyring +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + + +class SafeKeyring: + """Wrapper around keyring with file-based fallback for headless/container environments.""" + + @staticmethod + def set_password(service: str, username: str, password: str) -> None: + try: + keyring.set_password(service, username, password) + except Exception: + vault = SafeKeyring._load_vault() + vault[f"{service}:{username}"] = password + SafeKeyring._save_vault(vault) + + @staticmethod + def get_password(service: str, username: str) -> str | None: + try: + return keyring.get_password(service, username) + except Exception: + vault = SafeKeyring._load_vault() + return vault.get(f"{service}:{username}") + + @staticmethod + def delete_password(service: str, username: str) -> bool: + try: + keyring.delete_password(service, username) + return True + except Exception: + vault = SafeKeyring._load_vault() + key = f"{service}:{username}" + if key in vault: + del vault[key] + SafeKeyring._save_vault(vault) + return True + return False + + @staticmethod + def _get_vault_path() -> Path: + p = Path.home() / ".tcrm" + p.mkdir(parents=True, exist_ok=True) + return p / "vault.json" + + @staticmethod + def _load_vault() -> dict[str, str]: + path = SafeKeyring._get_vault_path() + if path.exists(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + + @staticmethod + def _save_vault(vault: dict[str, str]) -> None: + path = SafeKeyring._get_vault_path() + path.write_text(json.dumps(vault, indent=2), encoding="utf-8") + + +@dataclass +class EncryptedData: + """Container for encrypted data with metadata.""" + ciphertext: str # base64 encoded + salt: str # base64 encoded + iterations: int + algorithm: str = "PBKDF2-HMAC-SHA256" + + def to_json(self) -> str: + """Serialize to JSON string.""" + return json.dumps({ + "ciphertext": self.ciphertext, + "salt": self.salt, + "iterations": self.iterations, + "algorithm": self.algorithm, + }) + + @classmethod + def from_json(cls, json_str: str) -> "EncryptedData": + """Deserialize from JSON string.""" + data = json.loads(json_str) + return cls( + ciphertext=data["ciphertext"], + salt=data["salt"], + iterations=data["iterations"], + algorithm=data.get("algorithm", "PBKDF2-HMAC-SHA256"), + ) + + +class CryptoManager: + """Manages encryption/decryption with dynamic salts and keyring storage.""" + + # Keyring service name + KEYRING_SERVICE = "tcrm-toolkit" + + # PBKDF2 iterations (adjust based on security requirements) + DEFAULT_ITERATIONS = 214322 + + def __init__(self, master_key: str): + """Initialize with master key (base64 encoded 32 bytes).""" + self._master_key = base64.urlsafe_b64decode(master_key.encode()) + + def _derive_key(self, salt: bytes, iterations: int | None = None) -> bytes: + """Derive encryption key from master key and salt.""" + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=iterations or self.DEFAULT_ITERATIONS, + ) + return base64.urlsafe_b64encode(kdf.derive(self._master_key)) + + def encrypt(self, plaintext: str, iterations: int | None = None) -> EncryptedData: + """Encrypt plaintext with a random salt.""" + # Generate random salt + salt = os.urandom(16) + + # Derive key from salt + key = self._derive_key(salt, iterations) + + # Encrypt with Fernet + fernet = Fernet(key) + ciphertext = fernet.encrypt(plaintext.encode()) + + return EncryptedData( + ciphertext=base64.urlsafe_b64encode(ciphertext).decode(), + salt=base64.urlsafe_b64encode(salt).decode(), + iterations=iterations or self.DEFAULT_ITERATIONS, + ) + + def decrypt(self, encrypted_data: EncryptedData) -> str: + """Decrypt ciphertext using stored salt.""" + # Decode salt and ciphertext + salt = base64.urlsafe_b64decode(encrypted_data.salt.encode()) + ciphertext = base64.urlsafe_b64decode(encrypted_data.ciphertext.encode()) + + # Derive key from salt + key = self._derive_key(salt, encrypted_data.iterations) + + # Decrypt with Fernet + fernet = Fernet(key) + plaintext = fernet.decrypt(ciphertext) + + return plaintext.decode() + + def encrypt_json(self, data: dict[str, Any], iterations: int | None = None) -> EncryptedData: + """Encrypt a JSON-serializable dictionary.""" + return self.encrypt(json.dumps(data), iterations) + + def decrypt_json(self, encrypted_data: EncryptedData) -> dict[str, Any]: + """Decrypt and parse JSON.""" + return json.loads(self.decrypt(encrypted_data)) + + # Keyring integration for OAuth tokens + def store_token(self, username: str, token_data: dict[str, Any]) -> None: + """Store OAuth token data in system keyring.""" + encrypted = self.encrypt_json(token_data) + SafeKeyring.set_password( + self.KEYRING_SERVICE, + f"token:{username}", + encrypted.to_json(), + ) + + def retrieve_token(self, username: str) -> dict[str, Any] | None: + """Retrieve OAuth token data from system keyring.""" + stored = SafeKeyring.get_password(self.KEYRING_SERVICE, f"token:{username}") + if not stored: + return None + + try: + encrypted = EncryptedData.from_json(stored) + return self.decrypt_json(encrypted) + except Exception: + # If decryption fails, remove corrupted entry + SafeKeyring.delete_password(self.KEYRING_SERVICE, f"token:{username}") + return None + + def delete_token(self, username: str) -> bool: + """Delete stored token from keyring.""" + return SafeKeyring.delete_password(self.KEYRING_SERVICE, f"token:{username}") + + def list_stored_tokens(self) -> list[str]: + """List usernames with stored tokens.""" + # Note: keyring doesn't have a direct list method + # This is a placeholder for future implementation + return [] + + +def create_crypto_manager() -> CryptoManager: + """Factory function to create CryptoManager from settings.""" + from tcrm_toolkit.core.config import get_settings + settings = get_settings() + return CryptoManager(settings.encryption_key) diff --git a/tcrm_toolkit/core/exceptions.py b/tcrm_toolkit/core/exceptions.py new file mode 100644 index 0000000..817a81c --- /dev/null +++ b/tcrm_toolkit/core/exceptions.py @@ -0,0 +1,118 @@ +"""Custom exceptions for TCRM Toolkit.""" + + +class TCRMToolkitError(Exception): + """Base exception for TCRM Toolkit.""" + + def __init__(self, message: str, *args: object) -> None: + super().__init__(message, *args) + self.message = message + + +class ConfigurationError(TCRMToolkitError): + """Raised when configuration is invalid or missing.""" + + pass + + +class CryptoError(TCRMToolkitError): + """Raised when encryption/decryption fails.""" + + pass + + +class SalesforceAPIError(TCRMToolkitError): + """Raised when Salesforce API returns an error.""" + + def __init__( + self, + message: str, + status_code: int | None = None, + error_code: str | None = None, + *args: object, + ) -> None: + super().__init__(message, *args) + self.status_code = status_code + self.error_code = error_code + + +class SalesforceAuthError(SalesforceAPIError): + """Raised when authentication fails or token expires.""" + + def __init__(self, message: str = "Authentication failed or token expired", *args: object) -> None: + super().__init__(message, status_code=401, *args) + + +class SalesforceRateLimitError(SalesforceAPIError): + """Raised when rate limit is exceeded.""" + + def __init__( + self, + message: str = "Rate limit exceeded", + retry_after: int = 60, + *args: object, + ) -> None: + super().__init__(message, status_code=429, *args) + self.retry_after = retry_after + + +class SalesforceNotFoundError(SalesforceAPIError): + """Raised when a resource is not found.""" + + def __init__(self, message: str = "Resource not found", *args: object) -> None: + super().__init__(message, status_code=404, *args) + + +class OAuthError(TCRMToolkitError): + """Raised when OAuth flow fails.""" + + def __init__( + self, + message: str, + error_code: str | None = None, + *args: object, + ) -> None: + super().__init__(message, *args) + self.error_code = error_code + + +class TokenExpiredError(OAuthError): + """Raised when OAuth token has expired and cannot be refreshed.""" + + pass + + +class TokenNotFoundError(OAuthError): + """Raised when no token is found for the user.""" + + pass + + +class ValidationError(TCRMToolkitError): + """Raised when input validation fails.""" + + pass + + +class DatasetError(TCRMToolkitError): + """Raised when dataset operations fail.""" + + pass + + +class DashboardError(TCRMToolkitError): + """Raised when dashboard operations fail.""" + + pass + + +class DataflowError(TCRMToolkitError): + """Raised when dataflow operations fail.""" + + pass + + +class UploadError(TCRMToolkitError): + """Raised when file upload fails.""" + + pass diff --git a/tcrm_toolkit/core/logger.py b/tcrm_toolkit/core/logger.py new file mode 100644 index 0000000..506d107 --- /dev/null +++ b/tcrm_toolkit/core/logger.py @@ -0,0 +1,44 @@ +"""Centralized logging configuration for TCRM Toolkit.""" + +import logging +from pathlib import Path +import structlog + + +def setup_logging(log_file: Path | None = None, stream_logs: bool = True) -> None: + """Configure structured logging to stderr and a persistent log file.""" + log_file = log_file or (Path.home() / ".tcrm" / "tcrm.log") + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(log_file, encoding="utf-8") + except Exception: + file_handler = None + + handlers = [] + if stream_logs: + handlers.append(logging.StreamHandler()) + if file_handler: + handlers.append(file_handler) + + if not handlers: + handlers.append(logging.StreamHandler()) + + logging.basicConfig( + level=logging.INFO, + handlers=handlers, + force=True, + ) + + structlog.configure( + processors=[ + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.stdlib.BoundLogger, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) diff --git a/tcrm_toolkit/core/models/__init__.py b/tcrm_toolkit/core/models/__init__.py new file mode 100644 index 0000000..93b9015 --- /dev/null +++ b/tcrm_toolkit/core/models/__init__.py @@ -0,0 +1,320 @@ +"""Pydantic models for TCRM Toolkit domain objects.""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +# Progress Models (used by UI) +@dataclass +class ExtractionProgress: + """Progress information for dataset extraction.""" + total_rows: int + processed_rows: int + current_chunk: int + total_chunks: int + status: str + + +@dataclass +class UploadProgress: + """Progress information for CSV upload.""" + total_rows: int + uploaded_rows: int + current_part: int + total_parts: int + status: str + + +# Auth Models +class OAuthToken(BaseModel): + """OAuth token response from Salesforce.""" + access_token: str + refresh_token: str | None = None + instance_url: str + id: str + token_type: str = "Bearer" + issued_at: str | None = None + signature: str | None = None + scope: str | None = None + + model_config = ConfigDict(extra="allow") + + +class ConnectedAppConfig(BaseModel): + """Connected App configuration for JWT Bearer flow.""" + client_id: str + client_secret: str + username: str + domain: str = "login" + + model_config = ConfigDict(extra="allow") + + +class WebOAuthConfig(BaseModel): + """Web OAuth configuration for PKCE flow.""" + client_id: str + client_secret: str + redirect_uri: str + domain: str = "login" + scopes: list[str] = Field(default_factory=lambda: ["api", "refresh_token", "web"]) + + model_config = ConfigDict(extra="allow") + + +class DeviceFlowConfig(BaseModel): + """Device Authorization Flow configuration.""" + client_id: str + domain: str = "login" + + model_config = ConfigDict(extra="allow") + + +class DeviceAuthorizationResponse(BaseModel): + """Response from device authorization endpoint.""" + device_code: str + user_code: str + verification_uri: str + verification_uri_complete: str | None = None + expires_in: int + interval: int + + model_config = ConfigDict(extra="allow") + + +# Dataset Models +class DatasetField(BaseModel): + """Dataset field definition.""" + field: str + label: str + type: str + is_system: bool = False + is_unique: bool = False + is_nillable: bool = True + precision: int | None = None + scale: int | None = None + default_value: Any = None + + model_config = ConfigDict(extra="allow") + + +class DatasetXMD(BaseModel): + """Dataset Extended Metadata (XMD).""" + measures: list[dict[str, Any]] = Field(default_factory=list) + dimensions: list[dict[str, Any]] = Field(default_factory=list) + dates: list[dict[str, Any]] = Field(default_factory=list) + + model_config = ConfigDict(extra="allow") + + +class DatasetVersion(BaseModel): + """Dataset version information.""" + id: str + dataset_id: str + version_number: int + created_date: datetime + created_by_id: str + status: str + row_count: int | None = None + xmd: DatasetXMD | None = None + + model_config = ConfigDict(extra="allow") + + +class Dataset(BaseModel): + """Dataset model.""" + id: str + name: str + label: str + description: str | None = None + current_version_id: str | None = None + current_version_url: str | None = None + versions_url: str | None = None + histories_url: str | None = None + created_date: datetime | None = None + created_by_id: str | None = None + last_modified_date: datetime | None = None + last_modified_by_id: str | None = None + row_count: int | None = None + status: str = "Active" + type: str = "Edgemart" + + model_config = ConfigDict(extra="allow", alias_generator=to_camel, populate_by_name=True) + + +class DatasetListResponse(BaseModel): + """Response for dataset listing.""" + datasets: list[Dataset] + next_page_url: str | None = None + + model_config = ConfigDict(extra="allow") + + +class ExtractionJob(BaseModel): + """Dataset extraction job status.""" + id: str + dataset_id: str + status: Literal["pending", "running", "completed", "failed"] + total_rows: int = 0 + processed_rows: int = 0 + current_chunk: int = 0 + total_chunks: int = 0 + result_path: str | None = None + error: str | None = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + completed_at: datetime | None = None + + model_config = ConfigDict(extra="allow") + + +class UploadJob(BaseModel): + """CSV upload job status.""" + id: str + dataset_id: str + edgemart_alias: str + file_path: str + operation: Literal["Overwrite", "Append"] = "Overwrite" + status: Literal["pending", "uploading", "processing", "completed", "failed"] + total_rows: int = 0 + uploaded_rows: int = 0 + current_part: int = 0 + total_parts: int = 0 + external_data_id: str | None = None + error: str | None = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + completed_at: datetime | None = None + + model_config = ConfigDict(extra="allow") + + +# Dashboard Models +class Dashboard(BaseModel): + """Dashboard model.""" + id: str + name: str + label: str + description: str | None = None + folder_id: str | None = None + folder_name: str | None = None + created_date: datetime | None = None + created_by_id: str | None = None + last_modified_date: datetime | None = None + last_modified_by_id: str | None = None + histories_url: str | None = None + datasets_url: str | None = None + + model_config = ConfigDict(extra="allow", alias_generator=to_camel, populate_by_name=True) + + +class DashboardListResponse(BaseModel): + """Response for dashboard listing.""" + dashboards: list[Dashboard] + next_page_url: str | None = None + + model_config = ConfigDict(extra="allow") + + +class DashboardDataset(BaseModel): + """Dataset reference in a dashboard.""" + id: str + name: str + label: str + version_id: str | None = None + + model_config = ConfigDict(extra="allow") + + +class DashboardBackup(BaseModel): + """Dashboard JSON backup.""" + dashboard_id: str + dashboard_name: str + dashboard_label: str + json_definition: dict[str, Any] + backed_up_at: datetime = Field(default_factory=datetime.utcnow) + + model_config = ConfigDict(extra="allow") + + +# Dataflow Models +class Dataflow(BaseModel): + """Dataflow model.""" + id: str + name: str + label: str + description: str | None = None + status: str | None = None + created_date: datetime | None = None + created_by_id: str | None = None + last_modified_date: datetime | None = None + last_modified_by_id: str | None = None + histories_url: str | None = None + + model_config = ConfigDict(extra="allow", alias_generator=to_camel, populate_by_name=True) + + +class DataflowListResponse(BaseModel): + """Response for dataflow listing.""" + dataflows: list[Dataflow] + + model_config = ConfigDict(extra="allow") + + +class DataflowJob(BaseModel): + """Dataflow job execution.""" + id: str + dataflow_id: str + dataflow_name: str + command: Literal["start", "stop"] + status: Literal["Queued", "Running", "Success", "Failed", "Cancelled"] + start_time: datetime | None = None + end_time: datetime | None = None + error_message: str | None = None + + model_config = ConfigDict(extra="allow") + + +class DataflowJobListResponse(BaseModel): + """Response for dataflow job listing.""" + dataflowjobs: list[DataflowJob] + + model_config = ConfigDict(extra="allow") + + +# Limits Models +class APILimit(BaseModel): + """API limit information.""" + name: str + remaining: int + max: int + + model_config = ConfigDict(extra="allow") + + +class LimitsResponse(BaseModel): + """API limits response.""" + limits: list[APILimit] + + model_config = ConfigDict(extra="allow") + + +# Dependency Models +class Dependency(BaseModel): + """Dependency reference.""" + id: str + name: str + type: str + label: str | None = None + + model_config = ConfigDict(extra="allow") + + +class DependenciesResponse(BaseModel): + """Dependencies response.""" + dependencies: list[Dependency] + + model_config = ConfigDict(extra="allow") diff --git a/tcrm_toolkit/core/platform.py b/tcrm_toolkit/core/platform.py new file mode 100644 index 0000000..ed87fad --- /dev/null +++ b/tcrm_toolkit/core/platform.py @@ -0,0 +1,93 @@ +"""Cross-platform utilities for OS detection and paths.""" + +import os +import platform +from pathlib import Path +from typing import Literal + +OSType = Literal["windows", "linux", "darwin"] + + +def get_os() -> OSType: + """Detect current operating system.""" + system = platform.system().lower() + if system == "windows": + return "windows" + elif system == "darwin": + return "darwin" + return "linux" + + +def get_config_dir(app_name: str = "tcrm") -> Path: + """Get platform-appropriate config directory.""" + os_type = get_os() + + if os_type == "windows": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + elif os_type == "darwin": + base = Path.home() / "Library" / "Application Support" + else: # Linux/Unix + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + + path = base / app_name + path.mkdir(parents=True, exist_ok=True) + return path + + +def get_data_dir(app_name: str = "tcrm") -> Path: + """Get platform-appropriate data directory.""" + os_type = get_os() + + if os_type == "windows": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + elif os_type == "darwin": + base = Path.home() / "Library" / "Application Support" + else: # Linux/Unix + base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + + path = base / app_name + path.mkdir(parents=True, exist_ok=True) + return path + + +def get_cache_dir(app_name: str = "tcrm") -> Path: + """Get platform-appropriate cache directory.""" + os_type = get_os() + + if os_type == "windows": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "Cache" + elif os_type == "darwin": + base = Path.home() / "Library" / "Caches" + else: # Linux/Unix + base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) + + path = base / app_name + path.mkdir(parents=True, exist_ok=True) + return path + + +def is_windows() -> bool: + return get_os() == "windows" + + +def is_macos() -> bool: + return get_os() == "darwin" + + +def is_linux() -> bool: + return get_os() == "linux" + + +def get_terminal_size() -> tuple[int, int]: + """Get terminal size cross-platform.""" + try: + import shutil + return shutil.get_terminal_size() + except Exception: + return (80, 24) + + +def supports_true_color() -> bool: + """Check if terminal supports true color.""" + colorterm = os.environ.get("COLORTERM", "").lower() + return "truecolor" in colorterm or "24bit" in colorterm diff --git a/tcrm_toolkit/core/services/__init__.py b/tcrm_toolkit/core/services/__init__.py new file mode 100644 index 0000000..0d0f82e --- /dev/null +++ b/tcrm_toolkit/core/services/__init__.py @@ -0,0 +1,13 @@ +"""Core domain services for TCRM Toolkit.""" + +from tcrm_toolkit.core.services.auth_service import AuthService +from tcrm_toolkit.core.services.dashboard_service import DashboardService +from tcrm_toolkit.core.services.dataflow_service import DataflowService +from tcrm_toolkit.core.services.dataset_service import DatasetService + +__all__ = [ + "AuthService", + "DatasetService", + "DashboardService", + "DataflowService", +] diff --git a/tcrm_toolkit/core/services/auth_service.py b/tcrm_toolkit/core/services/auth_service.py new file mode 100644 index 0000000..ac56348 --- /dev/null +++ b/tcrm_toolkit/core/services/auth_service.py @@ -0,0 +1,430 @@ +"""Authentication service with pure Python OAuth flows.""" + +import asyncio +import base64 +import hashlib +import secrets +import time +import urllib.parse +from contextlib import asynccontextmanager +from dataclasses import dataclass +from urllib.parse import parse_qs, urlparse + +import httpx +import structlog +from authlib.integrations.httpx_client import AsyncOAuth2Client +from jose import jwt + +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.crypto import CryptoManager +from tcrm_toolkit.core.exceptions import ( + OAuthError, + TokenExpiredError, + TokenNotFoundError, +) +from tcrm_toolkit.core.models import ( + ConnectedAppConfig, + DeviceAuthorizationResponse, + DeviceFlowConfig, + OAuthToken, + WebOAuthConfig, +) + +logger = structlog.get_logger(__name__) + + +@dataclass +class PKCEChallenge: + """PKCE code verifier and challenge pair.""" + code_verifier: str + code_challenge: str + code_challenge_method: str = "S256" + + +class AuthService: + """Authentication service supporting multiple OAuth 2.0 flows.""" + + def __init__( + self, + settings: Settings | None = None, + crypto: CryptoManager | None = None, + ): + """Initialize the auth service.""" + self.settings = settings or get_settings() + self.crypto = crypto or CryptoManager() + self._http_client: httpx.AsyncClient | None = None + + @property + def http_client(self) -> httpx.AsyncClient: + """Get or create the HTTP client.""" + if self._http_client is None: + self._http_client = httpx.AsyncClient( + timeout=httpx.Timeout(30.0), + follow_redirects=True, + ) + return self._http_client + + async def close(self) -> None: + """Close the HTTP client.""" + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + # ========================================================================= + # PKCE Utilities + # ========================================================================= + + @staticmethod + def generate_pkce_challenge() -> PKCEChallenge: + """Generate PKCE code verifier and challenge.""" + code_verifier = secrets.token_urlsafe(32) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ).decode().rstrip("=") + return PKCEChallenge( + code_verifier=code_verifier, + code_challenge=code_challenge, + ) + + @staticmethod + def build_authorize_url( + config: WebOAuthConfig, + code_challenge: str, + state: str | None = None, + ) -> str: + """Build the authorization URL for Web PKCE flow.""" + params = { + "response_type": "code", + "client_id": config.client_id, + "redirect_uri": config.redirect_uri, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "scope": " ".join(config.scopes), + } + if state: + params["state"] = state + + base_url = f"https://{config.domain}.salesforce.com/services/oauth2/authorize" + return f"{base_url}?{urllib.parse.urlencode(params)}" + + # ========================================================================= + # JWT Bearer Flow (Connected App) + # ========================================================================= + + async def jwt_bearer_login(self, config: ConnectedAppConfig) -> OAuthToken: + """Authenticate using JWT Bearer flow for Connected Apps. + + This is the recommended flow for server-to-server automation. + """ + if not all([config.client_id, config.client_secret, config.username]): + raise OAuthError("Missing required Connected App credentials") + + # Create JWT assertion + now = int(time.time()) + claim = { + "iss": config.client_id, + "sub": config.username, + "aud": f"https://{config.domain}.salesforce.com/services/oauth2/token", + "exp": now + 300, # 5 minutes + "iat": now, + } + + # Sign with client_secret (HS256) - for production, use RS256 with certificate + assertion = jwt.encode(claim, config.client_secret, algorithm="HS256") + + token_url = f"https://{config.domain}.salesforce.com/services/oauth2/token" + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": assertion, + } + + async with AsyncOAuth2Client() as client: + response = await client.post(token_url, data=data) + response.raise_for_status() + token_data = response.json() + + return OAuthToken(**token_data) + + # ========================================================================= + # Web PKCE Flow + # ========================================================================= + + async def start_web_pkce_flow( + self, + config: WebOAuthConfig, + ) -> tuple[str, PKCEChallenge, str]: + """Start Web PKCE flow and return authorize URL, PKCE challenge, and state.""" + pkce = self.generate_pkce_challenge() + state = secrets.token_urlsafe(16) + authorize_url = self.build_authorize_url(config, pkce.code_challenge, state) + return authorize_url, pkce, state + + async def exchange_pkce_code( + self, + config: WebOAuthConfig, + code: str, + pkce: PKCEChallenge, + ) -> OAuthToken: + """Exchange authorization code for tokens in PKCE flow.""" + token_url = f"https://{config.domain}.salesforce.com/services/oauth2/token" + data = { + "grant_type": "authorization_code", + "client_id": config.client_id, + "client_secret": config.client_secret, + "code": code, + "redirect_uri": config.redirect_uri, + "code_verifier": pkce.code_verifier, + } + + async with AsyncOAuth2Client() as client: + response = await client.post(token_url, data=data) + response.raise_for_status() + token_data = response.json() + + return OAuthToken(**token_data) + + async def run_web_pkce_flow( + self, + config: WebOAuthConfig, + port: int = 8080, + ) -> OAuthToken: + """Run complete Web PKCE flow with local callback server.""" + authorize_url, pkce, state = await self.start_web_pkce_flow(config) + + # Start local server to receive callback + code_future: asyncio.Future[str] = asyncio.get_event_loop().create_future() + received_state: asyncio.Future[str] = asyncio.get_event_loop().create_future() + + async def callback_handler(request: httpx.Request) -> httpx.Response: + query = parse_qs(urlparse(str(request.url)).query) + if "code" in query: + code_future.set_result(query["code"][0]) + if "state" in query: + received_state.set_result(query["state"][0]) + return httpx.Response( + 200, + text="Authentication successful! You can close this window.", + headers={"Content-Type": "text/html"}, + ) + + # Note: In production, use a proper ASGI server like uvicorn + # For CLI, we'll use a simple approach with a temporary server + logger.info("web_pkce_started", authorize_url=authorize_url) + print(f"\nPlease open this URL in your browser:\n{authorize_url}\n") + + # For now, we'll prompt for the code manually + # A full implementation would start a local HTTP server + code = input("Enter the authorization code from the callback URL: ").strip() + if not code: + raise OAuthError("No authorization code provided") + + return await self.exchange_pkce_code(config, code, pkce) + + # ========================================================================= + # Device Authorization Flow + # ========================================================================= + + async def start_device_flow(self, config: DeviceFlowConfig) -> DeviceAuthorizationResponse: + """Start Device Authorization Flow.""" + token_url = f"https://{config.domain}.salesforce.com/services/oauth2/device_authorization" + data = {"client_id": config.client_id} + + async with AsyncOAuth2Client() as client: + response = await client.post(token_url, data=data) + response.raise_for_status() + return DeviceAuthorizationResponse(**response.json()) + + async def poll_device_token( + self, + config: DeviceFlowConfig, + device_code: str, + ) -> OAuthToken: + """Poll for device token.""" + token_url = f"https://{config.domain}.salesforce.com/services/oauth2/token" + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": config.client_id, + "device_code": device_code, + } + + async with AsyncOAuth2Client() as client: + response = await client.post(token_url, data=data) + + if response.status_code == 400: + error_data = response.json() + error = error_data.get("error") + if error == "authorization_pending": + raise OAuthError("Authorization pending", code="pending") + elif error == "slow_down": + raise OAuthError("Polling too fast", code="slow_down") + elif error == "expired_token": + raise OAuthError("Device code expired", code="expired") + elif error == "access_denied": + raise OAuthError("User denied authorization", code="access_denied") + else: + raise OAuthError(f"Device flow error: {error}") + + response.raise_for_status() + return OAuthToken(**response.json()) + + async def run_device_flow( + self, + config: DeviceFlowConfig, + ) -> OAuthToken: + """Run complete Device Authorization Flow.""" + device_auth = await self.start_device_flow(config) + + logger.info( + "device_flow_started", + user_code=device_auth.user_code, + verification_uri=device_auth.verification_uri, + ) + + print(f"\nPlease go to: {device_auth.verification_uri}") + print(f"And enter code: {device_auth.user_code}") + print(f"\nWaiting for authorization... (expires in {device_auth.expires_in}s)") + + interval = device_auth.interval or 5 + start_time = time.time() + + while time.time() - start_time < device_auth.expires_in: + try: + token = await self.poll_device_token(config, device_auth.device_code) + logger.info("device_flow_completed") + return token + except OAuthError as e: + if e.error_code == "pending": + await asyncio.sleep(interval) + continue + elif e.error_code == "slow_down": + interval += 5 + await asyncio.sleep(interval) + continue + else: + raise + + raise OAuthError("Device authorization timed out", code="timeout") + + # ========================================================================= + # Token Refresh + # ========================================================================= + + async def refresh_access_token( + self, + client_id: str, + client_secret: str, + refresh_token: str, + domain: str = "login", + ) -> OAuthToken: + """Refresh an expired access token using refresh token.""" + token_url = f"https://{domain}.salesforce.com/services/oauth2/token" + data = { + "grant_type": "refresh_token", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + + async with AsyncOAuth2Client() as client: + response = await client.post(token_url, data=data) + response.raise_for_status() + return OAuthToken(**response.json()) + + # ========================================================================= + # Token Storage (Keyring) + # ========================================================================= + + def store_tokens(self, username: str, token: OAuthToken) -> None: + """Store OAuth tokens securely in keyring.""" + token_data = { + "access_token": token.access_token, + "refresh_token": token.refresh_token, + "instance_url": token.instance_url, + "id": token.id, + "token_type": token.token_type, + "issued_at": token.issued_at, + "scope": token.scope, + } + self.crypto.store_token(username, token_data) + logger.info("tokens_stored", username=username) + + def retrieve_tokens(self, username: str) -> OAuthToken | None: + """Retrieve OAuth tokens from keyring.""" + token_data = self.crypto.retrieve_token(username) + if not token_data: + return None + return OAuthToken(**token_data) + + def delete_tokens(self, username: str) -> bool: + """Delete stored tokens from keyring.""" + return self.crypto.delete_token(username) + + # ========================================================================= + # Auto-Refresh Logic + # ========================================================================= + + def is_token_expired(self, token: OAuthToken, buffer_seconds: int = 300) -> bool: + """Check if token is expired or near expiry.""" + if not token.issued_at: + return True + try: + issued_at = int(token.issued_at) / 1000 # Convert from milliseconds + expires_in = 7200 # Default 2 hours for access token + return (time.time() - issued_at) > (expires_in - buffer_seconds) + except (ValueError, TypeError): + return True + + async def ensure_valid_token( + self, + username: str, + config: ConnectedAppConfig | WebOAuthConfig | DeviceFlowConfig, + ) -> OAuthToken: + """Get valid token, refreshing if necessary.""" + token = self.retrieve_tokens(username) + if not token: + raise TokenNotFoundError(f"No stored token for user: {username}") + + if not self.is_token_expired(token): + return token + + if not token.refresh_token: + raise TokenExpiredError("Token expired and no refresh token available") + + logger.info("refreshing_token", username=username) + + # Determine flow type and refresh + if isinstance(config, ConnectedAppConfig): + new_token = await self.jwt_bearer_login(config) + elif isinstance(config, WebOAuthConfig): + new_token = await self.refresh_access_token( + config.client_id, + config.client_secret, + token.refresh_token, + config.domain, + ) + elif isinstance(config, DeviceFlowConfig): + new_token = await self.refresh_access_token( + config.client_id, + config.client_secret, + token.refresh_token, + config.domain, + ) + else: + raise OAuthError("Unknown config type for token refresh") + + # Store new tokens + self.store_tokens(username, new_token) + return new_token + + +@asynccontextmanager +async def create_auth_service( + settings: Settings | None = None, + crypto: CryptoManager | None = None, +) -> AuthService: + """Context manager for creating and closing an AuthService.""" + service = AuthService(settings, crypto) + try: + yield service + finally: + await service.close() diff --git a/tcrm_toolkit/core/services/dashboard_service.py b/tcrm_toolkit/core/services/dashboard_service.py new file mode 100644 index 0000000..2038009 --- /dev/null +++ b/tcrm_toolkit/core/services/dashboard_service.py @@ -0,0 +1,138 @@ +"""Dashboard service for TCRM Toolkit.""" + +import json +from pathlib import Path + +import structlog + +from tcrm_toolkit.core.client import SalesforceClient +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.models import ( + Dashboard, + DashboardBackup, + DashboardDataset, + DashboardListResponse, +) + +logger = structlog.get_logger(__name__) + + +class DashboardService: + """Service for dashboard operations.""" + + def __init__( + self, + client: SalesforceClient, + settings: Settings | None = None, + ): + """Initialize the dashboard service.""" + self.client = client + self.settings = settings or get_settings() + + # ========================================================================= + # Listing and Retrieval + # ========================================================================= + + async def list_dashboards( + self, + page_size: int = 50, + sort: str = "Mru", + ) -> list[Dashboard]: + """List all dashboards with pagination.""" + all_dashboards = [] + page_token = None + + while True: + response = await self.client.list_dashboards( + page_size=page_size, + sort=sort, + page_token=page_token, + ) + data = DashboardListResponse(**response) + all_dashboards.extend(data.dashboards) + + if not data.next_page_url: + break + + page_token = self._extract_page_token(data.next_page_url) + + return all_dashboards + + async def get_dashboard(self, dashboard_id: str) -> Dashboard: + """Get dashboard details by ID.""" + response = await self.client.get_dashboard(dashboard_id) + return Dashboard(**response) + + async def get_dashboard_datasets(self, dashboard_id: str) -> list[DashboardDataset]: + """Get datasets used in a dashboard.""" + response = await self.client.get_dashboard_datasets(dashboard_id) + datasets = response.get("datasets", []) + return [DashboardDataset(**d) for d in datasets] + + # ========================================================================= + # Backup and Restore + # ========================================================================= + + async def backup_dashboard( + self, + dashboard_id: str, + output_path: Path | None = None, + ) -> DashboardBackup: + """Backup dashboard JSON definition.""" + dashboard = await self.get_dashboard(dashboard_id) + + # Get the full dashboard JSON + response = await self.client.get(f"{self.client.wave_base_url}/dashboards/{dashboard_id}") + json_definition = response.json() + + backup = DashboardBackup( + dashboard_id=dashboard_id, + dashboard_name=dashboard.name, + dashboard_label=dashboard.label, + json_definition=json_definition, + ) + + if output_path: + output_path.write_text(json.dumps(json_definition, indent=2)) + logger.info("dashboard_backup_saved", path=str(output_path)) + + return backup + + async def restore_dashboard( + self, + backup_path: Path, + new_name: str | None = None, + ) -> Dashboard: + """Restore dashboard from backup file.""" + json_definition = json.loads(backup_path.read_text()) + + if new_name: + json_definition["name"] = new_name + json_definition["label"] = new_name + + # Create new dashboard + response = await self.client.post( + f"{self.client.wave_base_url}/dashboards", + json=json_definition, + ) + return Dashboard(**response.json()) + + # ========================================================================= + # Deletion + # ========================================================================= + + async def delete_dashboard(self, dashboard_id: str) -> bool: + """Delete a dashboard.""" + await self.client.delete_dashboard(dashboard_id) + return True + + # ========================================================================= + # Helper Methods + # ========================================================================= + + def _extract_page_token(self, url: str) -> str | None: + """Extract page token from next page URL.""" + from urllib.parse import parse_qs, urlparse + parsed = urlparse(url) + params = parse_qs(parsed.query) + return params.get("pageToken", [None])[0] diff --git a/tcrm_toolkit/core/services/dataflow_service.py b/tcrm_toolkit/core/services/dataflow_service.py new file mode 100644 index 0000000..c52dd09 --- /dev/null +++ b/tcrm_toolkit/core/services/dataflow_service.py @@ -0,0 +1,127 @@ +"""Dataflow service for TCRM Toolkit.""" + +from typing import Any + +import structlog + +from tcrm_toolkit.core.client import SalesforceClient +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.exceptions import DataflowError +from tcrm_toolkit.core.models import ( + Dataflow, + DataflowJob, + DataflowJobListResponse, + DataflowListResponse, +) + +logger = structlog.get_logger(__name__) + + +class DataflowService: + """Service for dataflow operations.""" + + def __init__( + self, + client: SalesforceClient, + settings: Settings | None = None, + ): + """Initialize the dataflow service.""" + self.client = client + self.settings = settings or get_settings() + + # ========================================================================= + # Listing and Retrieval + # ========================================================================= + + async def list_dataflows(self) -> list[Dataflow]: + """List all dataflows.""" + response = await self.client.list_dataflows() + data = DataflowListResponse(**response) + return data.dataflows + + async def get_dataflow(self, dataflow_id: str) -> Dataflow: + """Get dataflow details by ID.""" + response = await self.client.get_dataflow(dataflow_id) + return Dataflow(**response) + + # ========================================================================= + # Execution Control + # ========================================================================= + + async def start_dataflow(self, dataflow_id: str) -> DataflowJob: + """Start a dataflow execution.""" + response = await self.client.start_dataflow(dataflow_id) + return DataflowJob(**response) + + async def stop_dataflow(self, dataflow_id: str) -> DataflowJob: + """Stop a running dataflow.""" + response = await self.client.stop_dataflow(dataflow_id) + return DataflowJob(**response) + + # ========================================================================= + # Job Monitoring + # ========================================================================= + + async def list_dataflow_jobs(self) -> list[DataflowJob]: + """List all dataflow jobs.""" + response = await self.client.list_dataflow_jobs() + data = DataflowJobListResponse(**response) + return data.dataflowjobs + + async def get_dataflow_job_status(self, job_id: str) -> DataflowJob | None: + """Get status of a specific dataflow job.""" + jobs = await self.list_dataflow_jobs() + for job in jobs: + if job.id == job_id: + return job + return None + + async def wait_for_dataflow_job( + self, + job_id: str, + poll_interval: int = 10, + timeout: int = 3600, + ) -> DataflowJob: + """Wait for a dataflow job to complete.""" + import asyncio + + start_time = asyncio.get_event_loop().time() + + while True: + job = await self.get_dataflow_job_status(job_id) + if not job: + raise DataflowError(f"Job {job_id} not found") + + if job.status in ("Success", "Failed", "Cancelled"): + return job + + if asyncio.get_event_loop().time() - start_time > timeout: + raise DataflowError(f"Job {job_id} timed out after {timeout}s") + + await asyncio.sleep(poll_interval) + + # ========================================================================= + # Backup + # ========================================================================= + + async def backup_dataflow( + self, + dataflow_id: str, + output_path: str | None = None, + ) -> dict[str, Any]: + """Backup current dataflow definition.""" + dataflow = await self.get_dataflow(dataflow_id) + + # Get the dataflow definition + response = await self.client.get( + f"{self.client.wave_base_url}/dataflows/{dataflow_id}" + ) + definition = response.json() + + if output_path: + import json + with open(output_path, "w") as f: + json.dump(definition, f, indent=2) + logger.info("dataflow_backup_saved", path=output_path) + + return definition diff --git a/tcrm_toolkit/core/services/dataset_service.py b/tcrm_toolkit/core/services/dataset_service.py new file mode 100644 index 0000000..ba87147 --- /dev/null +++ b/tcrm_toolkit/core/services/dataset_service.py @@ -0,0 +1,563 @@ +"""Dataset service for TCRM Toolkit.""" + +import asyncio +import base64 +import json +import math +from collections.abc import AsyncIterator, Callable +from pathlib import Path +from typing import Any + +import pandas as pd +import structlog + +from tcrm_toolkit.core.client import SalesforceClient +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.exceptions import DatasetError, UploadError +from tcrm_toolkit.core.models import ( + Dataset, + DatasetListResponse, + DatasetXMD, + ExtractionJob, + ExtractionProgress, + UploadJob, + UploadProgress, +) + +logger = structlog.get_logger(__name__) + + +class DatasetService: + """Service for dataset operations.""" + + def __init__( + self, + client: SalesforceClient, + settings: Settings | None = None, + ): + """Initialize the dataset service.""" + self.client = client + self.settings = settings or get_settings() + + # ========================================================================= + # Listing and Retrieval + # ========================================================================= + + async def list_datasets( + self, + page_size: int = 50, + sort: str = "Mru", + ) -> list[Dataset]: + """List all datasets with pagination.""" + all_datasets = [] + page_token = None + + while True: + response = await self.client.list_datasets( + page_size=page_size, + sort=sort, + page_token=page_token, + ) + data = DatasetListResponse(**response) + all_datasets.extend(data.datasets) + + if not data.next_page_url: + break + + # Extract page token from next_page_url + page_token = self._extract_page_token(data.next_page_url) + + return all_datasets + + async def get_dataset(self, dataset_id: str) -> Dataset: + """Get dataset details by ID.""" + response = await self.client.get_dataset(dataset_id) + return Dataset(**response) + + async def get_dataset_xmd(self, dataset_id: str, version_id: str) -> DatasetXMD: + """Get dataset XMD (Extended Metadata).""" + response = await self.client.get_dataset_xmd(dataset_id, version_id) + return DatasetXMD(**response) + + async def get_dataset_dependencies(self, dataset_id: str) -> list[dict[str, Any]]: + """Get dataset dependencies (downstream dataflows/dashboards).""" + response = await self.client.get_dataset_dependencies(dataset_id) + return response.get("dependencies", []) + + # ========================================================================= + # Extraction + # ========================================================================= + + def _calculate_chunk_size(self, total_rows: int) -> int: + """Calculate optimal chunk size based on row count.""" + if total_rows <= 5_000_000: + return 50_000 + return 150_000 + + def _build_saql_query( + self, + dataset_id: str, + version_id: str, + fields: list[str], + offset: int = 0, + limit: int | None = None, + ) -> str: + """Build SAQL query for dataset extraction.""" + fields_str = ", ".join(f"'{f}'" for f in fields) + query = f"q = load \"{dataset_id}/{version_id}\"; q = foreach q generate {fields_str};" + if offset > 0: + query += f" q = skip q {offset};" + if limit: + query += f" q = limit q {limit};" + return query + + def _extract_fields_from_xmd(self, xmd: DatasetXMD) -> list[str]: + """Extract field names from XMD, excluding system fields.""" + fields = [] + + # Add measures (excluding _epoch fields) + for measure in xmd.measures: + field = measure.get("field", "") + if field and not field.endswith("_epoch"): + fields.append(field) + + # Add dimensions (excluding date granularity fields) + excluded_suffixes = [ + "_Second", "_Minute", "_Hour", "_Day", "_Week", + "_Month", "_Quarter", "_Year", "_epoch", + ] + for dimension in xmd.dimensions: + field = dimension.get("field", "") + if field and not any(field.endswith(s) for s in excluded_suffixes): + fields.append(field) + + return fields + + async def get_row_count(self, dataset_id: str, version_id: str) -> int: + """Get total row count for a dataset using SAQL.""" + saql = f'q = load "{dataset_id}/{version_id}"; q = group q by all; q = foreach q generate count() as count;' + response = await self.client.saql_query(saql) + records = response.get("results", {}).get("records", []) + if records: + return records[0].get("count", 0) + return 0 + + async def extract_dataset( + self, + dataset_id: str, + output_path: Path, + progress_callback: Callable | None = None, + ) -> ExtractionJob: + """Extract dataset to CSV file with progress tracking.""" + # Get dataset info + dataset = await self.get_dataset(dataset_id) + version_id = dataset.current_version_id + if not version_id: + raise DatasetError(f"Dataset {dataset_id} has no current version") + + # Get XMD to determine fields + xmd = await self.get_dataset_xmd(dataset_id, version_id) + fields = self._extract_fields_from_xmd(xmd) + + if not fields: + raise DatasetError("No valid fields found in dataset XMD") + + # Get total row count + total_rows = await self.get_row_count(dataset_id, version_id) + if total_rows == 0: + logger.warning("dataset_empty", dataset_id=dataset_id) + # Create empty CSV with headers + df = pd.DataFrame(columns=fields) + df.to_csv(output_path, index=False) + return ExtractionJob( + id=f"extract-{dataset_id}", + dataset_id=dataset_id, + status="completed", + total_rows=0, + processed_rows=0, + current_chunk=0, + total_chunks=0, + result_path=str(output_path), + ) + + # Calculate chunking + chunk_size = self._calculate_chunk_size(total_rows) + total_chunks = math.ceil(total_rows / chunk_size) + + logger.info( + "extraction_started", + dataset_id=dataset_id, + total_rows=total_rows, + chunk_size=chunk_size, + total_chunks=total_chunks, + ) + + # Create job record + job = ExtractionJob( + id=f"extract-{dataset_id}", + dataset_id=dataset_id, + status="running", + total_rows=total_rows, + total_chunks=total_chunks, + ) + + # Extract in chunks + all_chunks = [] + for chunk_num in range(total_chunks): + offset = chunk_num * chunk_size + saql = self._build_saql_query( + dataset_id, version_id, fields, offset, chunk_size + ) + + response = await self.client.saql_query(saql) + records = response.get("results", {}).get("records", []) + + if records: + chunk_df = pd.DataFrame(records) + all_chunks.append(chunk_df) + + job.processed_rows += len(records) + job.current_chunk = chunk_num + 1 + + if progress_callback: + progress = ExtractionProgress( + total_rows=total_rows, + processed_rows=job.processed_rows, + current_chunk=job.current_chunk, + total_chunks=total_chunks, + status="running", + ) + if asyncio.iscoroutinefunction(progress_callback): + await progress_callback(progress) + else: + progress_callback(progress) + + # Combine and save + if all_chunks: + combined_df = pd.concat(all_chunks, ignore_index=True) + combined_df.to_csv(output_path, index=False) + else: + # Empty dataset + pd.DataFrame(columns=fields).to_csv(output_path, index=False) + + job.status = "completed" + job.result_path = str(output_path) + job.completed_at = pd.Timestamp.utcnow().to_pydatetime() + + logger.info( + "extraction_completed", + dataset_id=dataset_id, + rows=job.processed_rows, + output_path=str(output_path), + ) + + return job + + async def extract_dataset_streaming( + self, + dataset_id: str, + output_path: Path, + progress_callback: Callable | None = None, + ) -> AsyncIterator[ExtractionProgress]: + """Extract dataset to CSV with streaming progress updates.""" + dataset = await self.get_dataset(dataset_id) + version_id = dataset.current_version_id + if not version_id: + raise DatasetError(f"Dataset {dataset_id} has no current version") + + xmd = await self.get_dataset_xmd(dataset_id, version_id) + fields = self._extract_fields_from_xmd(xmd) + + if not fields: + raise DatasetError("No valid fields found in dataset XMD") + + total_rows = await self.get_row_count(dataset_id, version_id) + if total_rows == 0: + pd.DataFrame(columns=fields).to_csv(output_path, index=False) + yield ExtractionProgress(0, 0, 0, 0, "completed") + return + + chunk_size = self._calculate_chunk_size(total_rows) + total_chunks = math.ceil(total_rows / chunk_size) + + # Write header first + header_written = False + + for chunk_num in range(total_chunks): + offset = chunk_num * chunk_size + saql = self._build_saql_query( + dataset_id, version_id, fields, offset, chunk_size + ) + + response = await self.client.saql_query(saql) + records = response.get("results", {}).get("records", []) + + if records: + chunk_df = pd.DataFrame(records) + # Write chunk to CSV (append mode after first chunk) + chunk_df.to_csv( + output_path, + mode="a" if header_written else "w", + header=not header_written, + index=False, + ) + header_written = True + + processed = min((chunk_num + 1) * chunk_size, total_rows) + + progress = ExtractionProgress( + total_rows=total_rows, + processed_rows=processed, + current_chunk=chunk_num + 1, + total_chunks=total_chunks, + status="running", + ) + yield progress + + if progress_callback: + await progress_callback(progress) + + yield ExtractionProgress( + total_rows=total_rows, + processed_rows=total_rows, + current_chunk=total_chunks, + total_chunks=total_chunks, + status="completed", + ) + + # ========================================================================= + # CSV Upload + # ========================================================================= + + def _generate_metadata_json(self, df: pd.DataFrame, dataset_name: str) -> str: + """Generate metadata JSON for CSV upload.""" + fields = [] + for col in df.columns: + dtype = str(df[col].dtype) + if dtype in ("int64", "int32", "int16", "int8"): + field_type = "Numeric" + elif dtype in ("float64", "float32"): + field_type = "Numeric" + elif dtype == "bool": + field_type = "Text" # Boolean as text + elif dtype.startswith("datetime"): + field_type = "Date" + else: + field_type = "Text" + + fields.append({ + "name": col, + "label": col.replace("_", " ").title(), + "type": field_type, + "isNullable": True, + }) + + metadata = { + "format": "Csv", + "edgemartAlias": dataset_name, + "fields": fields, + } + return base64.b64encode(json.dumps(metadata).encode()).decode() + + async def upload_csv( + self, + dataset_id: str, + file_path: Path, + dataset_name: str | None = None, + operation: str = "Overwrite", + chunk_size: int = 50000, + progress_callback: Callable | None = None, + ) -> UploadJob: + """Upload CSV file to dataset with chunked multipart upload.""" + if not file_path.exists(): + raise UploadError(f"File not found: {file_path}") + + # Get dataset info if not provided + if not dataset_name: + dataset = await self.get_dataset(dataset_id) + dataset_name = dataset.name + + # Read first chunk to generate metadata + first_chunk = pd.read_csv(file_path, nrows=1) + metadata_json = self._generate_metadata_json(first_chunk, dataset_name) + + # Create external data job + job_response = await self.client.create_insights_external_data( + edgemart_alias=dataset_name, + metadata_json=metadata_json, + operation=operation, + ) + external_data_id = job_response["id"] + + # Count total rows + total_rows = sum(1 for _ in open(file_path)) - 1 # Subtract header + total_parts = math.ceil(total_rows / chunk_size) + + job = UploadJob( + id=f"upload-{dataset_id}", + dataset_id=dataset_id, + edgemart_alias=dataset_name, + file_path=str(file_path), + operation=operation, + status="uploading", + total_rows=total_rows, + total_parts=total_parts, + external_data_id=external_data_id, + ) + + logger.info( + "upload_started", + dataset_id=dataset_id, + external_data_id=external_data_id, + total_rows=total_rows, + total_parts=total_parts, + ) + + # Upload in chunks + part_number = 1 + uploaded_rows = 0 + + for chunk in pd.read_csv(file_path, chunksize=chunk_size): + csv_data = chunk.to_csv(index=False) + data_file_base64 = base64.b64encode(csv_data.encode()).decode() + + await self.client.upload_insights_external_data_part( + external_data_id=external_data_id, + part_number=part_number, + data_file_base64=data_file_base64, + ) + + uploaded_rows += len(chunk) + job.uploaded_rows = uploaded_rows + job.current_part = part_number + + if progress_callback: + progress = UploadProgress( + total_rows=total_rows, + uploaded_rows=uploaded_rows, + current_part=part_number, + total_parts=total_parts, + status="uploading", + ) + await progress_callback(progress) + + part_number += 1 + + # Process the data + job.status = "processing" + if progress_callback: + await progress_callback(UploadProgress( + total_rows=total_rows, + uploaded_rows=uploaded_rows, + current_part=total_parts, + total_parts=total_parts, + status="processing", + )) + + result = await self.client.process_insights_external_data(external_data_id) + + job.status = "completed" + job.completed_at = pd.Timestamp.utcnow().to_pydatetime() + + logger.info( + "upload_completed", + dataset_id=dataset_id, + external_data_id=external_data_id, + rows=uploaded_rows, + ) + + return job + + async def upload_csv_streaming( + self, + dataset_id: str, + file_path: Path, + dataset_name: str | None = None, + operation: str = "Overwrite", + chunk_size: int = 50000, + ) -> AsyncIterator[UploadProgress]: + """Upload CSV file with streaming progress updates.""" + if not file_path.exists(): + raise UploadError(f"File not found: {file_path}") + + if not dataset_name: + dataset = await self.get_dataset(dataset_id) + dataset_name = dataset.name + + first_chunk = pd.read_csv(file_path, nrows=1) + metadata_json = self._generate_metadata_json(first_chunk, dataset_name) + + job_response = await self.client.create_insights_external_data( + edgemart_alias=dataset_name, + metadata_json=metadata_json, + operation=operation, + ) + external_data_id = job_response["id"] + + total_rows = sum(1 for _ in open(file_path)) - 1 + total_parts = math.ceil(total_rows / chunk_size) + + part_number = 1 + uploaded_rows = 0 + + for chunk in pd.read_csv(file_path, chunksize=chunk_size): + csv_data = chunk.to_csv(index=False) + data_file_base64 = base64.b64encode(csv_data.encode()).decode() + + await self.client.upload_insights_external_data_part( + external_data_id=external_data_id, + part_number=part_number, + data_file_base64=data_file_base64, + ) + + uploaded_rows += len(chunk) + + yield UploadProgress( + total_rows=total_rows, + uploaded_rows=uploaded_rows, + current_part=part_number, + total_parts=total_parts, + status="uploading", + ) + + part_number += 1 + + yield UploadProgress( + total_rows=total_rows, + uploaded_rows=uploaded_rows, + current_part=total_parts, + total_parts=total_parts, + status="processing", + ) + + result = await self.client.process_insights_external_data(external_data_id) + + yield UploadProgress( + total_rows=total_rows, + uploaded_rows=uploaded_rows, + current_part=total_parts, + total_parts=total_parts, + status="completed", + ) + + # ========================================================================= + # Deletion + # ========================================================================= + + async def delete_dataset(self, dataset_id: str) -> bool: + """Delete a dataset.""" + await self.client.delete_dataset(dataset_id) + return True + + # ========================================================================= + # Helper Methods + # ========================================================================= + + def _extract_page_token(self, url: str) -> str | None: + """Extract page token from next page URL.""" + parsed = urlparse(url) + params = parse_qs(parsed.query) + return params.get("pageToken", [None])[0] + + +from urllib.parse import parse_qs, urlparse diff --git a/tcrm_toolkit/core/sf_cli.py b/tcrm_toolkit/core/sf_cli.py new file mode 100644 index 0000000..eedf733 --- /dev/null +++ b/tcrm_toolkit/core/sf_cli.py @@ -0,0 +1,363 @@ +"""SF CLI wrapper for managing Salesforce CLI authentication.""" + +import asyncio +import json +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +import structlog + +logger = structlog.get_logger(__name__) + + +@dataclass +class SFCLIAuthResult: + """Result from SF CLI authentication.""" + access_token: str + instance_url: str + refresh_token: str | None = None + expires_at: datetime | None = None + alias: str = "default" + username: str | None = None + + +class SFCLIError(Exception): + """SF CLI operation error.""" + pass + + +class SFCLINotFoundError(SFCLIError): + """SF CLI not installed or not in PATH.""" + pass + + +class SFCLIManager: + """Manages SF CLI subprocess calls for authentication.""" + + def __init__(self, cli_command: str = "sf"): + """ + Initialize SF CLI manager. + + Args: + cli_command: SF CLI command name ('sf' or 'sfdx') + """ + self.cli_command = cli_command + self._cli_path: str | None = None + + def is_available(self) -> bool: + """Check if SF CLI is installed and available.""" + self._cli_path = self._find_cli_path() + return self._cli_path is not None + + def _find_cli_path(self) -> str | None: + """Find SF CLI executable path, checking common locations.""" + # First try standard PATH lookup + path = shutil.which(self.cli_command) + if path: + return path + + # On Windows, check common installation locations + import os + import sys + + if sys.platform == "win32": + # Common Windows locations for SF CLI + possible_paths = [ + # npm global bin + os.path.expandvars(r"%APPDATA%\npm\sf.cmd"), + os.path.expandvars(r"%APPDATA%\npm\sfdx.cmd"), + # Local npm bin + os.path.expandvars(r"%LOCALAPPDATA%\npm\sf.cmd"), + os.path.expandvars(r"%LOCALAPPDATA%\npm\sfdx.cmd"), + # Program Files + os.path.expandvars(r"%PROGRAMFILES%\Salesforce CLI\bin\sf.cmd"), + os.path.expandvars(r"%PROGRAMFILES%\Salesforce CLI\bin\sfdx.cmd"), + os.path.expandvars(r"%PROGRAMFILES(X86)%\Salesforce CLI\bin\sf.cmd"), + os.path.expandvars(r"%PROGRAMFILES(X86)%\Salesforce CLI\bin\sfdx.cmd"), + # User profile + os.path.expandvars(r"%USERPROFILE%\AppData\Local\sf\bin\sf.cmd"), + os.path.expandvars(r"%USERPROFILE%\AppData\Local\sf\bin\sfdx.cmd"), + ] + + for p in possible_paths: + if os.path.exists(p): + return p + + # On Unix-like systems, check common locations + else: + possible_paths = [ + "/usr/local/bin/sf", + "/usr/local/bin/sfdx", + "/opt/sfdx/bin/sf", + "/opt/sfdx/bin/sfdx", + os.path.expanduser("~/.local/bin/sf"), + os.path.expanduser("~/.local/bin/sfdx"), + ] + + for p in possible_paths: + if os.path.exists(p): + return p + + return None + + def _run_command(self, args: list[str], timeout: int = 120) -> subprocess.CompletedProcess: + """ + Run SF CLI command synchronously. + + Args: + args: Command arguments + timeout: Timeout in seconds + + Returns: + CompletedProcess result + + Raises: + SFCLIError: If command fails + SFCLINotFoundError: If CLI not found + """ + if not self.is_available(): + raise SFCLINotFoundError( + f"SF CLI ('{self.cli_command}') not found. " + "Install from https://developer.salesforce.com/tools/sfdxcli" + ) + + cmd = [self._cli_path] + args + logger.debug("running_sf_cli", command=cmd) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as e: + raise SFCLIError(f"SF CLI command timed out after {timeout}s: {cmd}") from e + except FileNotFoundError as e: + raise SFCLINotFoundError(f"SF CLI not found: {self.cli_command}") from e + except Exception as e: + raise SFCLIError(f"Failed to run SF CLI: {e}") from e + + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() + # SF CLI sometimes returns non-zero for warnings (e.g., update available) + # Check if it's just a warning and the command actually succeeded + if error_msg and "warning" in error_msg.lower() and "update available" in error_msg.lower(): + logger.warning("sf_cli_warning", command=cmd, warning=error_msg) + # Return the result anyway since the command likely succeeded + return result + logger.error("sf_cli_failed", command=cmd, returncode=result.returncode, error=error_msg) + raise SFCLIError(f"SF CLI failed (exit {result.returncode}): {error_msg}") + + return result + + async def _run_command_async(self, args: list[str], timeout: int = 120) -> subprocess.CompletedProcess: + """Run SF CLI command asynchronously.""" + if not self.is_available(): + raise SFCLINotFoundError( + f"SF CLI ('{self.cli_command}') not found. " + "Install from https://developer.salesforce.com/tools/sfdxcli" + ) + + cmd = [self._cli_path] + args + logger.debug("running_sf_cli_async", command=cmd) + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout, + ) + except TimeoutError as e: + raise SFCLIError(f"SF CLI command timed out after {timeout}s: {cmd}") from e + except FileNotFoundError as e: + raise SFCLINotFoundError(f"SF CLI not found: {self.cli_command}") from e + except Exception as e: + raise SFCLIError(f"Failed to run SF CLI: {e}") from e + + result = subprocess.CompletedProcess( + args=cmd, + returncode=process.returncode or 0, + stdout=stdout.decode() if stdout else "", + stderr=stderr.decode() if stderr else "", + ) + + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() + # SF CLI sometimes returns non-zero for warnings (e.g., update available) + # Check if it's just a warning and the command actually succeeded + if error_msg and "warning" in error_msg.lower() and "update available" in error_msg.lower(): + logger.warning("sf_cli_warning", command=cmd, warning=error_msg) + # Return the result anyway since the command likely succeeded + return result + logger.error("sf_cli_failed", command=cmd, returncode=result.returncode, error=error_msg) + raise SFCLIError(f"SF CLI failed (exit {result.returncode}): {error_msg}") + + return result + + async def login_web( + self, + alias: str = "default", + instance_url: str | None = None, + timeout: int = 300, + ) -> SFCLIAuthResult: + """ + Run SF CLI web login flow. + + Args: + alias: Org alias to use + instance_url: Optional custom instance URL (e.g., https://mydomain.my.salesforce.com) + timeout: Timeout in seconds for the login flow + + Returns: + SFCLIAuthResult with authentication details + """ + args = ["org", "login", "web", "--alias", alias, "--json"] + if instance_url: + args.extend(["--instance-url", instance_url]) + + logger.info("starting_sf_cli_web_login", alias=alias, instance_url=instance_url) + + result = await self._run_command_async(args, timeout=timeout) + + # Parse the JSON output + try: + output = json.loads(result.stdout) + except json.JSONDecodeError as e: + raise SFCLIError(f"Failed to parse SF CLI JSON output: {e}") from e + + # The login command returns the org info directly + if output.get("status") != 0: + raise SFCLIError(f"Login failed: {output.get('message', 'Unknown error')}") + + result_data = output.get("result", {}) + return self._parse_auth_result(result_data, alias) + + async def login_device( + self, + alias: str = "default", + instance_url: str | None = None, + timeout: int = 300, + ) -> SFCLIAuthResult: + """ + Run SF CLI device login flow (for headless environments). + + Args: + alias: Org alias to use + instance_url: Optional custom instance URL + timeout: Timeout in seconds for the login flow + + Returns: + SFCLIAuthResult with authentication details + """ + args = ["org", "login", "device", "--alias", alias, "--json"] + if instance_url: + args.extend(["--instance-url", instance_url]) + + logger.info("starting_sf_cli_device_login", alias=alias, instance_url=instance_url) + + result = await self._run_command_async(args, timeout=timeout) + + try: + output = json.loads(result.stdout) + except json.JSONDecodeError as e: + raise SFCLIError(f"Failed to parse SF CLI JSON output: {e}") from e + + if output.get("status") != 0: + raise SFCLIError(f"Device login failed: {output.get('message', 'Unknown error')}") + + result_data = output.get("result", {}) + return self._parse_auth_result(result_data, alias) + + async def get_org_info(self, alias: str = "default") -> SFCLIAuthResult: + """ + Get org info including access token. + + Args: + alias: Org alias + + Returns: + SFCLIAuthResult with current auth details + """ + args = ["org", "display", "--target-org", alias, "--json"] + + result = await self._run_command_async(args) + + try: + output = json.loads(result.stdout) + except json.JSONDecodeError as e: + raise SFCLIError(f"Failed to parse SF CLI JSON output: {e}") from e + + if output.get("status") != 0: + raise SFCLIError(f"Failed to get org info: {output.get('message', 'Unknown error')}") + + result_data = output.get("result", {}) + return self._parse_auth_result(result_data, alias) + + async def refresh_token(self, alias: str = "default") -> SFCLIAuthResult: + """ + Force token refresh by getting org info (SF CLI handles refresh internally). + + Args: + alias: Org alias + + Returns: + SFCLIAuthResult with refreshed auth details + """ + # SF CLI automatically refreshes tokens when running org display + return await self.get_org_info(alias) + + async def logout(self, alias: str = "default") -> None: + """ + Logout and remove org from SF CLI. + + Args: + alias: Org alias to remove + """ + args = ["org", "logout", "--target-org", alias, "--json", "--no-prompt"] + await self._run_command_async(args) + logger.info("sf_cli_logout", alias=alias) + + def _parse_auth_result(self, data: dict[str, Any], alias: str) -> SFCLIAuthResult: + """Parse SF CLI auth result into SFCLIAuthResult.""" + access_token = data.get("accessToken") + instance_url = data.get("instanceUrl") + refresh_token = data.get("refreshToken") + username = data.get("username") + + # Parse expiration if available + expires_at = None + if "tokenExpiration" in data: + try: + expires_at = datetime.fromisoformat(data["tokenExpiration"].replace("Z", "+00:00")) + except (ValueError, TypeError): + pass + + if not access_token or not instance_url: + raise SFCLIError("Missing access token or instance URL in SF CLI response") + + return SFCLIAuthResult( + access_token=access_token, + instance_url=instance_url.rstrip("/"), + refresh_token=refresh_token, + expires_at=expires_at, + alias=alias, + username=username, + ) + + def list_orgs(self) -> list[dict[str, Any]]: + """List all authorized orgs.""" + result = self._run_command(["org", "list", "--json", "--all"]) + try: + output = json.loads(result.stdout) + return output.get("result", {}).get("orgs", []) + except (json.JSONDecodeError, KeyError): + return [] diff --git a/tcrm_toolkit/interactive/__init__.py b/tcrm_toolkit/interactive/__init__.py new file mode 100644 index 0000000..45cb9f9 --- /dev/null +++ b/tcrm_toolkit/interactive/__init__.py @@ -0,0 +1,5 @@ +"""Interactive TUI module for CRMA Toolkit.""" + +from tcrm_toolkit.interactive.app import TCRMApp + +__all__ = ["TCRMApp"] diff --git a/tcrm_toolkit/interactive/app.py b/tcrm_toolkit/interactive/app.py new file mode 100644 index 0000000..57dce91 --- /dev/null +++ b/tcrm_toolkit/interactive/app.py @@ -0,0 +1,219 @@ +"""Main Textual App for CRMA Toolkit Interactive TUI.""" + +import asyncio +import signal +import sys + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Container +from textual.widgets import Footer, Header + +from tcrm_toolkit.core.config import get_settings +from tcrm_toolkit.core.crypto import create_crypto_manager +from tcrm_toolkit.interactive.safety import RiskLevel, SafetyMonitor, SafetyResult +from tcrm_toolkit.interactive.screens.login_screen import LoginScreen +from tcrm_toolkit.interactive.screens.main_screen import MainScreen +from tcrm_toolkit.interactive.screens.org_picker import OrgPickerScreen +from tcrm_toolkit.interactive.screens.safety_modal import SafetyModalScreen +from tcrm_toolkit.interactive.session import SessionManager +from tcrm_toolkit.interactive.widgets.status_bar import StatusBar + + +class TCRMApp(App): + """ + Main Interactive TUI Application for CRMA Toolkit. + """ + + CSS_PATH = "styles/dark.css" + + BINDINGS = [ + Binding("ctrl+q", "quit", "Quit", show=True), + Binding("ctrl+p", "command_palette", "Command Palette", show=True), + Binding("ctrl+o", "org_picker", "Switch Org", show=True), + Binding("ctrl+r", "refresh", "Refresh", show=True), + Binding("f1", "help", "Help", show=True), + Binding("escape", "escape", "Back/Cancel", show=False), + ] + + def __init__(self, **kwargs): + from tcrm_toolkit.core.logger import setup_logging + setup_logging(stream_logs=False) + + from tcrm_toolkit.interactive.config_manager import ConfigManager + from tcrm_toolkit.interactive.window_manager import WindowManager + from tcrm_toolkit.interactive.tasks import TaskRunner + + self.config_manager = ConfigManager() + self.tui_config = self.config_manager.load() + self.window_manager = WindowManager() + self.task_runner = TaskRunner() + + if self.tui_config.theme == "light": + type(self).CSS_PATH = "styles/light.css" + else: + type(self).CSS_PATH = "styles/dark.css" + + super().__init__(**kwargs) + self.settings = get_settings() + self.crypto = create_crypto_manager() + self.safety = SafetyMonitor(self.settings) + self.session = SessionManager( + settings=self.settings, + crypto=self.crypto, + safety_monitor=self.safety, + ) + self._main_screen: MainScreen | None = None + self._safety_check_interval = self.settings.safety_check_interval + self._shutdown_event = asyncio.Event() + + def compose(self) -> ComposeResult: + """Compose the app layout.""" + yield Header(show_clock=True) + yield Container(id="main-container") + yield StatusBar(id="status-bar") + yield Footer() + + def _setup_signal_handlers(self) -> None: + """Set up signal handlers for graceful shutdown.""" + if sys.platform != "win32": + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, self._handle_shutdown_signal, sig) + except NotImplementedError: + # Signal handling not available on this platform + pass + + def _handle_shutdown_signal(self, sig: signal.Signals) -> None: + """Handle shutdown signal by initiating clean exit.""" + self._shutdown_event.set() + # Exit directly - signal handler runs in event loop context + self.exit() + + async def on_mount(self) -> None: + """Initialize app on mount.""" + self._setup_signal_handlers() + self.safety.start_monitoring(callback=self._on_safety_update) + + try: + await self.session.initialize() + except Exception as e: + self.notify(f"Session init failed: {e}", severity="error") + + safety_result = await self.safety.check_connection_safety() + if safety_result.risk_level == RiskLevel.CRITICAL and self.settings.safety_block_on_critical: + self.push_screen(SafetyModalScreen(safety_result), self._on_safety_modal_dismiss) + else: + await self._show_main_screen() + + async def _on_safety_update(self, result: SafetyResult) -> None: + """Handle safety monitor updates.""" + status_bar = self.query_one("#status-bar", StatusBar) + status_bar.update_safety(result) + + if result.risk_level == RiskLevel.CRITICAL and self.settings.safety_block_on_critical: + if not self.screen_stack or not isinstance(self.screen_stack[-1], SafetyModalScreen): + self.push_screen(SafetyModalScreen(result), self._on_safety_modal_dismiss) + + def _on_safety_modal_dismiss(self, action: str) -> None: + """Handle safety modal dismissal.""" + if action == "retry": + self.run_worker(self._recheck_safety_and_continue()) + else: + self.exit() + + async def _recheck_safety_and_continue(self) -> None: + result = await self.safety.check_connection_safety(force=True) + if result.risk_level == RiskLevel.CRITICAL: + self.push_screen(SafetyModalScreen(result), self._on_safety_modal_dismiss) + else: + await self._show_main_screen() + + async def _show_main_screen(self) -> None: + while len(self.screen_stack) > 1: + self.pop_screen() + + if not self.session.current_org: + self.push_screen(LoginScreen(self.session), self._on_login_complete) + else: + await self._mount_main_screen() + + def _on_login_complete(self, success: bool) -> None: + if success: + self.run_worker(self._mount_main_screen()) + else: + self.notify("Login failed", severity="error") + self.push_screen(LoginScreen(self.session), self._on_login_complete) + + async def _mount_main_screen(self) -> None: + if self._main_screen is None: + self._main_screen = MainScreen(self.session, self.safety, self.task_runner) + + container = self.query_one("#main-container", Container) + await container.mount(self._main_screen) + self._main_screen.focus() + + async def action_command_palette(self) -> None: + if self._main_screen: + await self._main_screen.action_command_palette() + + async def action_help(self) -> None: + from tcrm_toolkit.interactive.screens.help_screen import HelpScreen + self.push_screen(HelpScreen()) + + async def action_org_picker(self) -> None: + orgs = self.session.list_orgs() + if not orgs: + self.notify("No orgs configured. Run 'sf org login web' first.", severity="warning") + return + + def on_org_selected(alias: str) -> None: + self.run_worker(self._switch_org(alias)) + + self.push_screen(OrgPickerScreen(orgs, self.session.current_alias), on_org_selected) + + async def _switch_org(self, alias: str) -> None: + try: + await self.session.switch_org(alias) + self.notify(f"Switched to org: {alias}", severity="information") + if self._main_screen: + await self._main_screen.refresh_data() + status_bar = self.query_one("#status-bar", StatusBar) + status_bar.update_org(self.session.current_org) + except Exception as e: + self.notify(f"Failed to switch org: {e}", severity="error") + + async def action_refresh(self) -> None: + if self._main_screen: + await self._main_screen.refresh_data() + self.notify("Refreshed", severity="information", timeout=2) + + async def action_escape(self) -> None: + if self._main_screen: + await self._main_screen.action_escape() + + async def on_unmount(self) -> None: + self._shutdown_event.set() + self.safety.stop_monitoring() + await self.task_runner.close() + await self.session.close() + await self.safety.close() + + def on_error(self, event) -> None: + """Capture unhandled async worker/UI errors into structured logs.""" + import structlog + logger = structlog.get_logger(__name__) + logger.exception("tui_unhandled_error", error=str(getattr(event, "error", event))) + self.notify(f"Error: {getattr(event, 'error', event)}", severity="error", timeout=5) + event.prevent_default() + + +def main() -> None: + app = TCRMApp() + app.run() + + +if __name__ == "__main__": + main() + diff --git a/tcrm_toolkit/interactive/config.py b/tcrm_toolkit/interactive/config.py new file mode 100644 index 0000000..2dab78f --- /dev/null +++ b/tcrm_toolkit/interactive/config.py @@ -0,0 +1,49 @@ +"""TUI Configuration settings.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class TUIConfig(BaseSettings): + """Configuration for Interactive TUI.""" + + model_config = SettingsConfigDict( + env_prefix="TCRM_TUI_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # Appearance + theme: Literal["dark", "light", "auto"] = "dark" + keybindings: Literal["vim", "standard"] = "standard" + show_line_numbers: bool = False + + # Layout + sidebar_width: int = 25 + detail_panel_width: int = 30 + status_bar_height: int = 1 + + # Behavior + confirm_destructive: bool = True + auto_refresh_interval: int = 10 # seconds for job monitoring + max_history_items: int = 100 + + # Performance + browser_page_size: int = 50 + search_debounce_ms: int = 300 + + # Paths + config_dir: Path = Field(default_factory=lambda: Path.home() / ".tcrm") + history_file: Path = Field(default_factory=lambda: Path.home() / ".tcrm" / "history.json") + + def __init__(self, **values): + super().__init__(**values) + self.config_dir.mkdir(parents=True, exist_ok=True) + diff --git a/tcrm_toolkit/interactive/config_manager.py b/tcrm_toolkit/interactive/config_manager.py new file mode 100644 index 0000000..d0a38f0 --- /dev/null +++ b/tcrm_toolkit/interactive/config_manager.py @@ -0,0 +1,45 @@ +"""Configuration persistence for TUI settings.""" + +import json +from pathlib import Path + +from tcrm_toolkit.interactive.config import TUIConfig + + +class ConfigManager: + """Manages persistent TUI configuration.""" + + def __init__(self, config_dir: Path | None = None): + self.config_dir = config_dir or (Path.home() / ".tcrm") + self.config_file = self.config_dir / "config.json" + self._config: TUIConfig | None = None + + def load(self) -> TUIConfig: + """Load configuration from disk or create default.""" + if self._config is not None: + return self._config + + self.config_dir.mkdir(parents=True, exist_ok=True) + if self.config_file.exists(): + try: + data = json.loads(self.config_file.read_text(encoding="utf-8")) + # Convert path strings back if present + if "config_dir" in data: + data["config_dir"] = Path(data["config_dir"]) + if "history_file" in data: + data["history_file"] = Path(data["history_file"]) + self._config = TUIConfig(**data) + except Exception: + self._config = TUIConfig() + else: + self._config = TUIConfig() + self.save(self._config) + + return self._config + + def save(self, config: TUIConfig) -> None: + """Save configuration to disk.""" + self._config = config + self.config_dir.mkdir(parents=True, exist_ok=True) + data = config.model_dump(mode="json") + self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/tcrm_toolkit/interactive/notifications.py b/tcrm_toolkit/interactive/notifications.py new file mode 100644 index 0000000..0e7b2ce --- /dev/null +++ b/tcrm_toolkit/interactive/notifications.py @@ -0,0 +1,40 @@ +"""Enhanced notification manager with history and severity.""" + +from datetime import datetime +from typing import NamedTuple + + +class NotificationRecord(NamedTuple): + message: str + severity: str + timestamp: datetime + timeout: float | None + + +class NotificationManager: + """Manages notification history and presentation.""" + + def __init__(self, max_history: int = 100): + self.max_history = max_history + self.history: list[NotificationRecord] = [] + + def record(self, message: str, severity: str = "information", timeout: float | None = None) -> NotificationRecord: + """Record and return a notification item.""" + rec = NotificationRecord( + message=message, + severity=severity, + timestamp=datetime.utcnow(), + timeout=timeout, + ) + self.history.append(rec) + if len(self.history) > self.max_history: + self.history.pop(0) + return rec + + def get_history(self) -> list[NotificationRecord]: + """Get notification history.""" + return list(self.history) + + def clear(self) -> None: + """Clear notification history.""" + self.history.clear() diff --git a/tcrm_toolkit/interactive/operations/__init__.py b/tcrm_toolkit/interactive/operations/__init__.py new file mode 100644 index 0000000..7c8046a --- /dev/null +++ b/tcrm_toolkit/interactive/operations/__init__.py @@ -0,0 +1 @@ +"""Interactive operations.""" diff --git a/tcrm_toolkit/interactive/operations/dashboard_backup.py b/tcrm_toolkit/interactive/operations/dashboard_backup.py new file mode 100644 index 0000000..5097150 --- /dev/null +++ b/tcrm_toolkit/interactive/operations/dashboard_backup.py @@ -0,0 +1,127 @@ +"""Dashboard backup and restore operations.""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import structlog + +from tcrm_toolkit.core.services.dashboard_service import DashboardService +from tcrm_toolkit.interactive.tasks import TaskRunner + +logger = structlog.get_logger(__name__) + + +class DashboardBackupManager: + """Manager for dashboard backup and restore operations.""" + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[dict[str, Any]], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + + async def backup_dashboard( + self, + dashboard_id: str, + output_path: Path, + ) -> dict[str, Any]: + """Backup single dashboard to JSON file.""" + async with self.session.client_context() as client: + service = DashboardService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "fetching", "dashboard_id": dashboard_id}) + + backup = await service.backup_dashboard(dashboard_id, output_path) + + if self.progress_callback: + self.progress_callback({"status": "completed", "path": str(output_path)}) + + return { + "dashboard_id": dashboard_id, + "dashboard_name": backup.dashboard_name, + "path": str(output_path), + } + + async def backup_all_dashboards( + self, + output_dir: Path, + pattern: str | None = None, + ) -> dict[str, Any]: + """Backup all dashboards to directory.""" + output_dir.mkdir(parents=True, exist_ok=True) + + async with self.session.client_context() as client: + service = DashboardService(client, self.session.settings) + dashboards = await service.list_dashboards() + + if pattern: + import fnmatch + dashboards = [d for d in dashboards if fnmatch.fnmatch(d.label, pattern)] + + results = [] + for i, dashboard in enumerate(dashboards): + if self.progress_callback: + self.progress_callback({ + "status": "backing_up", + "current": i + 1, + "total": len(dashboards), + "dashboard": dashboard.label, + }) + + try: + output_path = output_dir / f"{dashboard.name}.json" + await service.backup_dashboard(dashboard.id, output_path) + results.append({ + "id": dashboard.id, + "name": dashboard.name, + "label": dashboard.label, + "path": str(output_path), + "status": "success", + }) + except Exception as e: + results.append({ + "id": dashboard.id, + "name": dashboard.name, + "label": dashboard.label, + "error": str(e), + "status": "failed", + }) + + if self.progress_callback: + self.progress_callback({"status": "completed", "results": results}) + + return { + "total": len(dashboards), + "successful": sum(1 for r in results if r["status"] == "success"), + "failed": sum(1 for r in results if r["status"] == "failed"), + "results": results, + } + + async def restore_dashboard( + self, + backup_path: Path, + new_name: str | None = None, + ) -> dict[str, Any]: + """Restore dashboard from backup file.""" + async with self.session.client_context() as client: + service = DashboardService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "restoring", "path": str(backup_path)}) + + dashboard = await service.restore_dashboard(backup_path, new_name) + + if self.progress_callback: + self.progress_callback({"status": "completed", "dashboard_id": dashboard.id}) + + return { + "dashboard_id": dashboard.id, + "dashboard_name": dashboard.name, + "source": str(backup_path), + } diff --git a/tcrm_toolkit/interactive/operations/dashboard_ops.py b/tcrm_toolkit/interactive/operations/dashboard_ops.py new file mode 100644 index 0000000..d0f2352 --- /dev/null +++ b/tcrm_toolkit/interactive/operations/dashboard_ops.py @@ -0,0 +1,62 @@ +"""Dashboard operations for Interactive TUI.""" + +from typing import Any + +from tcrm_toolkit.core.models import Dashboard +from tcrm_toolkit.core.services.dashboard_service import DashboardService +from tcrm_toolkit.interactive.widgets.data_table import ColumnConfig, DataBrowser + + +def create_dashboard_browser(session) -> DataBrowser[Dashboard]: + """Create configured dashboard browser.""" + + columns = [ + ColumnConfig(key="id", title="ID", width=18, formatter=lambda x: x[:15] + "..." if len(str(x)) > 18 else str(x)), + ColumnConfig(key="name", title="Name", width=30), + ColumnConfig(key="label", title="Label", width=30), + ColumnConfig(key="folder_name", title="Folder", width=25, formatter=lambda x: x or "N/A"), + ColumnConfig(key="created_date", title="Created", width=20, formatter=lambda x: x.strftime("%Y-%m-%d") if x else "N/A"), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + async with session.client_context() as client: + service = DashboardService(client, session.settings) + sort_key = sort.split(":")[0] if sort else "Mru" + all_dashboards = await service.list_dashboards(page_size=1000, sort=sort_key) + + if search: + search_lower = search.lower() + all_dashboards = [ + db for db in all_dashboards + if search_lower in db.name.lower() + or search_lower in db.label.lower() + or search_lower in (db.folder_name or "").lower() + or search_lower in db.id.lower() + ] + + total = len(all_dashboards) + page_data = all_dashboards[offset:offset + limit] + return page_data, total + + def get_row_id(dashboard: Dashboard) -> str: + return dashboard.id + + def get_row_data(dashboard: Dashboard) -> dict[str, Any]: + return { + "id": dashboard.id, + "name": dashboard.name, + "label": dashboard.label, + "folder_name": dashboard.folder_name or "N/A", + "created_date": dashboard.created_date, + } + + return DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="📈 Dashboards", + page_size=50, + id="dashboards-browser", + ) + diff --git a/tcrm_toolkit/interactive/operations/dataflow_control.py b/tcrm_toolkit/interactive/operations/dataflow_control.py new file mode 100644 index 0000000..4f2278a --- /dev/null +++ b/tcrm_toolkit/interactive/operations/dataflow_control.py @@ -0,0 +1,106 @@ +"""Dataflow start/stop/monitor operations.""" + +import asyncio +from collections.abc import Callable +from typing import Any + +import structlog + +from tcrm_toolkit.core.services.dataflow_service import DataflowService +from tcrm_toolkit.interactive.tasks import TaskRunner + +logger = structlog.get_logger(__name__) + + +class DataflowController: + """Control dataflow execution with job monitoring.""" + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[dict[str, Any]], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + + async def start_dataflow(self, dataflow_id: str) -> dict[str, Any]: + """Start dataflow and return job info.""" + async with self.session.client_context() as client: + service = DataflowService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "starting", "dataflow_id": dataflow_id}) + + job = await service.start_dataflow(dataflow_id) + + if self.progress_callback: + self.progress_callback({"status": "started", "job_id": job.id}) + + return { + "job_id": job.id, + "dataflow_id": dataflow_id, + "status": job.status, + } + + async def stop_dataflow(self, dataflow_id: str) -> dict[str, Any]: + """Stop running dataflow.""" + async with self.session.client_context() as client: + service = DataflowService(client, self.session.settings) + + if self.progress_callback: + self.progress_callback({"status": "stopping", "dataflow_id": dataflow_id}) + + job = await service.stop_dataflow(dataflow_id) + + if self.progress_callback: + self.progress_callback({"status": "stopped", "job_id": job.id}) + + return { + "job_id": job.id, + "dataflow_id": dataflow_id, + "status": job.status, + } + + async def wait_for_job( + self, + job_id: str, + poll_interval: int = 10, + timeout: int = 3600, + ) -> dict[str, Any]: + """Wait for dataflow job to complete with progress updates.""" + async with self.session.client_context() as client: + service = DataflowService(client, self.session.settings) + + start_time = asyncio.get_event_loop().time() + + while True: + job = await service.get_dataflow_job_status(job_id) + if not job: + raise ValueError(f"Job {job_id} not found") + + if self.progress_callback: + self.progress_callback({ + "status": "polling", + "job_id": job_id, + "job_status": job.status, + }) + + if job.status in ("Success", "Failed", "Cancelled"): + if self.progress_callback: + self.progress_callback({ + "status": "completed", + "job_id": job_id, + "final_status": job.status, + }) + return { + "job_id": job.id, + "status": job.status, + "dataflow_name": job.dataflow_name, + } + + if asyncio.get_event_loop().time() - start_time > timeout: + raise TimeoutError(f"Job {job_id} timed out after {timeout}s") + + await asyncio.sleep(poll_interval) diff --git a/tcrm_toolkit/interactive/operations/dataflow_ops.py b/tcrm_toolkit/interactive/operations/dataflow_ops.py new file mode 100644 index 0000000..c0eeae5 --- /dev/null +++ b/tcrm_toolkit/interactive/operations/dataflow_ops.py @@ -0,0 +1,144 @@ +"""Dataflow operations for Interactive TUI.""" + +import asyncio +from typing import Any + +from textual.widgets import DataTable + +from tcrm_toolkit.core.models import Dataflow, DataflowJob +from tcrm_toolkit.core.services.dataflow_service import DataflowService +from tcrm_toolkit.interactive.widgets.data_table import ColumnConfig, DataBrowser + + +def create_dataflow_browser(session) -> DataBrowser[Dataflow]: + """Create configured dataflow browser.""" + + columns = [ + ColumnConfig(key="id", title="ID", width=18, formatter=lambda x: x[:15] + "..." if len(str(x)) > 18 else str(x)), + ColumnConfig(key="name", title="Name", width=30), + ColumnConfig(key="label", title="Label", width=30), + ColumnConfig(key="status", title="Status", width=15), + ColumnConfig(key="created_date", title="Created", width=20, formatter=lambda x: x.strftime("%Y-%m-%d") if x else "N/A"), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + async with session.client_context() as client: + service = DataflowService(client, session.settings) + all_dataflows = await service.list_dataflows() + + if search: + search_lower = search.lower() + all_dataflows = [ + df for df in all_dataflows + if search_lower in df.name.lower() + or search_lower in df.label.lower() + or search_lower in df.id.lower() + ] + + total = len(all_dataflows) + page_data = all_dataflows[offset:offset + limit] + return page_data, total + + def get_row_id(dataflow: Dataflow) -> str: + return dataflow.id + + def get_row_data(dataflow: Dataflow) -> dict[str, Any]: + return { + "id": dataflow.id, + "name": dataflow.name, + "label": dataflow.label, + "status": dataflow.status, + "created_date": dataflow.created_date, + } + + return DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="🔄 Dataflows", + page_size=50, + id="dataflows-browser", + ) + + +def create_dataflow_job_browser(session) -> DataBrowser[DataflowJob]: + """Create configured dataflow job browser with live polling.""" + + columns = [ + ColumnConfig(key="id", title="Job ID", width=18, formatter=lambda x: x[:15] + "..." if len(str(x)) > 18 else str(x)), + ColumnConfig(key="dataflow_name", title="Dataflow", width=30), + ColumnConfig(key="command", title="Command", width=12), + ColumnConfig(key="status", title="Status", width=15), + ColumnConfig(key="start_time", title="Started", width=20, formatter=lambda x: x.strftime("%Y-%m-%d %H:%M") if x else "N/A"), + ColumnConfig(key="end_time", title="Ended", width=20, formatter=lambda x: x.strftime("%Y-%m-%d %H:%M") if x else "N/A"), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + async with session.client_context() as client: + service = DataflowService(client, session.settings) + all_jobs = await service.list_dataflow_jobs() + + if search: + search_lower = search.lower() + all_jobs = [ + job for job in all_jobs + if search_lower in job.dataflow_name.lower() + or search_lower in job.command.lower() + or search_lower in job.status.lower() + or search_lower in job.id.lower() + ] + + all_jobs.sort(key=lambda j: j.start_time or "", reverse=True) + + total = len(all_jobs) + page_data = all_jobs[offset:offset + limit] + return page_data, total + + def get_row_id(job: DataflowJob) -> str: + return job.id + + def get_row_data(job: DataflowJob) -> dict[str, Any]: + return { + "id": job.id, + "dataflow_name": job.dataflow_name, + "command": job.command, + "status": job.status, + "start_time": job.start_time, + "end_time": job.end_time, + } + + browser = DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="📋 Dataflow Jobs", + page_size=50, + id="jobs-browser", + ) + + original_on_mount = browser.on_mount + + async def on_mount_with_polling(self) -> None: + await original_on_mount() + self._poll_task = asyncio.create_task(self._poll_running_jobs()) + + async def _poll_running_jobs(self) -> None: + while True: + try: + await asyncio.sleep(10) + table = self.query_one("#data-table", DataTable) + has_running = any( + "running" in str(table.get_cell_at(row, 3)).lower() + for row in range(table.row_count) + ) + if has_running: + await self.reload_data() + except Exception: + pass + + browser.on_mount = on_mount_with_polling.__get__(browser, DataBrowser) + + return browser + diff --git a/tcrm_toolkit/interactive/operations/dataset_extract.py b/tcrm_toolkit/interactive/operations/dataset_extract.py new file mode 100644 index 0000000..118bd4d --- /dev/null +++ b/tcrm_toolkit/interactive/operations/dataset_extract.py @@ -0,0 +1,185 @@ +"""Parallel dataset extraction with multiprocessing for large datasets.""" + +import asyncio +import math +import shutil +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pandas as pd +import structlog + +from tcrm_toolkit.core.models import ExtractionProgress +from tcrm_toolkit.core.services.dataset_service import DatasetService +from tcrm_toolkit.interactive.tasks import TaskRunner, merge_csv_chunks + +logger = structlog.get_logger(__name__) + + +class ParallelDatasetExtractor: + """ + Extract large datasets using parallel SAQL queries and multiprocessing merge. + + Strategy: + 1. Get total row count via SAQL + 2. Calculate optimal chunk size (50k-150k rows) + 3. Run SAQL queries in parallel (async, I/O-bound) with semaphore + 4. Save each chunk to temp CSV + 5. Merge chunks using ProcessPoolExecutor (CPU-bound) + 6. Stream progress updates throughout + """ + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[ExtractionProgress], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + self._temp_dir: Path | None = None + + async def extract( + self, + dataset_id: str, + output_path: Path, + max_concurrent_queries: int = 10, + ) -> dict[str, Any]: + """ + Extract dataset to CSV with parallel processing. + + Args: + dataset_id: Dataset ID to extract + output_path: Output CSV file path + max_concurrent_queries: Max parallel SAQL queries + + Returns: + Dict with extraction stats + """ + self._temp_dir = Path(tempfile.mkdtemp(prefix=f"tcrm_extract_{dataset_id}_")) + + try: + async with self.session.client_context() as client: + service = DatasetService(client, self.session.settings) + + # Get dataset info + dataset = await service.get_dataset(dataset_id) + version_id = dataset.current_version_id + if not version_id: + raise ValueError(f"Dataset {dataset_id} has no current version") + + # Get XMD for field list + xmd = await service.get_dataset_xmd(dataset_id, version_id) + fields = service._extract_fields_from_xmd(xmd) + + if not fields: + raise ValueError("No valid fields found in dataset XMD") + + # Get total row count + total_rows = await service.get_row_count(dataset_id, version_id) + + if total_rows == 0: + pd.DataFrame(columns=fields).to_csv(output_path, index=False) + return {"rows": 0, "chunks": 0, "output": str(output_path)} + + # Calculate chunking + chunk_size = service._calculate_chunk_size(total_rows) + total_chunks = math.ceil(total_rows / chunk_size) + + logger.info( + "parallel_extract_started", + dataset_id=dataset_id, + total_rows=total_rows, + chunk_size=chunk_size, + total_chunks=total_chunks, + ) + + progress = ExtractionProgress( + total_rows=total_rows, + processed_rows=0, + current_chunk=0, + total_chunks=total_chunks, + status="running", + ) + + semaphore = asyncio.Semaphore(max_concurrent_queries) + + async def extract_chunk(chunk_num: int) -> tuple[int, Path | None]: + async with semaphore: + offset = chunk_num * chunk_size + saql = service._build_saql_query( + dataset_id, version_id, fields, offset, chunk_size + ) + + response = await client.saql_query(saql) + records = response.get("results", {}).get("records", []) + + if records: + chunk_df = pd.DataFrame(records) + assert self._temp_dir is not None + chunk_path = self._temp_dir / f"chunk_{chunk_num:04d}.csv" + chunk_df.to_csv(chunk_path, index=False) + return len(records), chunk_path + + return 0, None + + chunk_tasks = [extract_chunk(i) for i in range(total_chunks)] + + chunk_results = [] + for i, coro in enumerate(asyncio.as_completed(chunk_tasks)): + rows, chunk_path = await coro + chunk_results.append((rows, chunk_path)) + + progress.processed_rows += rows + progress.current_chunk = i + 1 + + if self.progress_callback: + if asyncio.iscoroutinefunction(self.progress_callback): + await self.progress_callback(progress) + else: + self.progress_callback(progress) + + successful_chunks = [p for r, p in chunk_results if r > 0 and p is not None] + total_processed = sum(r for r, _ in chunk_results) + + progress.status = "merging" + if self.progress_callback: + if asyncio.iscoroutinefunction(self.progress_callback): + await self.progress_callback(progress) + else: + self.progress_callback(progress) + + merge_result = await self.task_runner.run_in_process_pool( + merge_csv_chunks, + [str(p) for p in successful_chunks], + str(output_path), + ) + + progress.status = "completed" + progress.current_chunk = total_chunks + if self.progress_callback: + if asyncio.iscoroutinefunction(self.progress_callback): + await self.progress_callback(progress) + else: + self.progress_callback(progress) + + logger.info( + "parallel_extract_completed", + dataset_id=dataset_id, + total_rows=total_processed, + chunks=len(successful_chunks), + ) + + return { + "rows": total_processed, + "chunks": len(successful_chunks), + "output": str(output_path), + "merge_result": merge_result, + } + + finally: + if self._temp_dir and self._temp_dir.exists(): + shutil.rmtree(self._temp_dir, ignore_errors=True) diff --git a/tcrm_toolkit/interactive/operations/dataset_ops.py b/tcrm_toolkit/interactive/operations/dataset_ops.py new file mode 100644 index 0000000..21f7911 --- /dev/null +++ b/tcrm_toolkit/interactive/operations/dataset_ops.py @@ -0,0 +1,66 @@ +"""Dataset operations for Interactive TUI.""" + +from typing import Any + +from tcrm_toolkit.core.models import Dataset +from tcrm_toolkit.core.services.dataset_service import DatasetService +from tcrm_toolkit.interactive.widgets.data_table import ColumnConfig, DataBrowser + + +def create_dataset_browser(session) -> DataBrowser[Dataset]: + """Create configured dataset browser.""" + + columns = [ + ColumnConfig(key="id", title="ID", width=18, formatter=lambda x: x[:15] + "..." if len(str(x)) > 18 else str(x)), + ColumnConfig(key="name", title="Name", width=30), + ColumnConfig(key="label", title="Label", width=30), + ColumnConfig(key="row_count", title="Rows", width=12, formatter=lambda x: f"{x:,}" if x else "N/A"), + ColumnConfig(key="status", title="Status", width=12), + ColumnConfig(key="type", title="Type", width=15), + ] + + async def load_data(offset: int, limit: int, search: str | None, sort: str | None): + """Load datasets with pagination.""" + async with session.client_context() as client: + service = DatasetService(client, session.settings) + + sort_key = sort.split(":")[0] if sort else "Mru" + all_datasets = await service.list_datasets(page_size=1000, sort=sort_key) + + if search: + search_lower = search.lower() + all_datasets = [ + ds for ds in all_datasets + if search_lower in ds.name.lower() + or search_lower in ds.label.lower() + or search_lower in ds.id.lower() + ] + + total = len(all_datasets) + page_data = all_datasets[offset:offset + limit] + + return page_data, total + + def get_row_id(dataset: Dataset) -> str: + return dataset.id + + def get_row_data(dataset: Dataset) -> dict[str, Any]: + return { + "id": dataset.id, + "name": dataset.name, + "label": dataset.label, + "row_count": dataset.row_count or 0, + "status": dataset.status, + "type": dataset.type, + } + + return DataBrowser( + columns=columns, + load_data=load_data, + get_row_id=get_row_id, + get_row_data=get_row_data, + title="📊 Datasets", + page_size=50, + id="datasets-browser", + ) + diff --git a/tcrm_toolkit/interactive/operations/dataset_upload.py b/tcrm_toolkit/interactive/operations/dataset_upload.py new file mode 100644 index 0000000..5de72f7 --- /dev/null +++ b/tcrm_toolkit/interactive/operations/dataset_upload.py @@ -0,0 +1,165 @@ +"""Parallel dataset upload with multiprocessing for CSV processing.""" + +import asyncio +import math +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pandas as pd +import structlog + +from tcrm_toolkit.core.models import UploadProgress +from tcrm_toolkit.core.services.dataset_service import DatasetService +from tcrm_toolkit.interactive.tasks import ( + TaskRunner, + process_csv_chunk, + split_csv_for_parallel, +) + +logger = structlog.get_logger(__name__) + + +def _process_chunks_parallel(chunk_args: list[tuple]) -> list[dict[str, Any]]: + """Top-level helper to process multiple chunks in process pool.""" + return [process_csv_chunk(args) for args in chunk_args] + + +class ParallelDatasetUploader: + """ + Upload large CSV to dataset using parallel chunk processing. + + Strategy: + 1. Read CSV metadata (first row) + 2. Create InsightsExternalData job + 3. Split CSV into chunks for parallel base64 encoding + 4. Process chunks in ProcessPoolExecutor (CPU-bound) + 5. Upload parts sequentially (API requirement) + 6. Trigger processing + """ + + def __init__( + self, + session, + task_runner: TaskRunner, + progress_callback: Callable[[UploadProgress], None] | None = None, + ): + self.session = session + self.task_runner = task_runner + self.progress_callback = progress_callback + + async def upload( + self, + dataset_id: str, + file_path: Path, + dataset_name: str | None = None, + operation: str = "Overwrite", + chunk_size: int = 50000, + max_process_workers: int = 4, + ) -> dict[str, Any]: + """ + Upload CSV to dataset with parallel chunk processing. + """ + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + async with self.session.client_context() as client: + service = DatasetService(client, self.session.settings) + + if not dataset_name: + dataset = await service.get_dataset(dataset_id) + dataset_name = dataset.name + + first_chunk = pd.read_csv(file_path, nrows=1) + metadata_json = service._generate_metadata_json(first_chunk, dataset_name) + + job_response = await client.create_insights_external_data( + edgemart_alias=dataset_name, + metadata_json=metadata_json, + operation=operation, + ) + external_data_id = job_response["id"] + + total_rows = sum(1 for _ in open(file_path)) - 1 + total_parts = math.ceil(total_rows / chunk_size) + + logger.info( + "parallel_upload_started", + dataset_id=dataset_id, + external_data_id=external_data_id, + total_rows=total_rows, + total_parts=total_parts, + ) + + progress = UploadProgress( + total_rows=total_rows, + uploaded_rows=0, + current_part=0, + total_parts=total_parts, + status="uploading", + ) + + with tempfile.TemporaryDirectory() as tmpdir: + chunk_paths = await self.task_runner.run_in_process_pool( + split_csv_for_parallel, + str(file_path), + max_process_workers, + tmpdir, + ) + + chunk_args = [(path, i, total_parts) for i, path in enumerate(chunk_paths)] + + processed_chunks = await self.task_runner.run_in_process_pool( + _process_chunks_parallel, + chunk_args, + ) + + uploaded_rows = 0 + for _i, chunk_result in enumerate(processed_chunks): + await client.upload_insights_external_data_part( + external_data_id=external_data_id, + part_number=chunk_result["part_number"], + data_file_base64=chunk_result["data_file_base64"], + ) + + uploaded_rows += chunk_result["rows"] + progress.uploaded_rows = uploaded_rows + progress.current_part = chunk_result["part_number"] + + if self.progress_callback: + if asyncio.iscoroutinefunction(self.progress_callback): + await self.progress_callback(progress) + else: + self.progress_callback(progress) + + progress.status = "processing" + if self.progress_callback: + if asyncio.iscoroutinefunction(self.progress_callback): + await self.progress_callback(progress) + else: + self.progress_callback(progress) + + result = await client.process_insights_external_data(external_data_id) + + progress.status = "completed" + if self.progress_callback: + if asyncio.iscoroutinefunction(self.progress_callback): + await self.progress_callback(progress) + else: + self.progress_callback(progress) + + logger.info( + "parallel_upload_completed", + dataset_id=dataset_id, + external_data_id=external_data_id, + rows=uploaded_rows, + ) + + return { + "external_data_id": external_data_id, + "dataset_id": dataset_id, + "rows": uploaded_rows, + "status": "completed", + "result": result, + } diff --git a/tcrm_toolkit/interactive/platform.py b/tcrm_toolkit/interactive/platform.py new file mode 100644 index 0000000..6510cc9 --- /dev/null +++ b/tcrm_toolkit/interactive/platform.py @@ -0,0 +1,26 @@ +"""Re-export platform utilities.""" +from tcrm_toolkit.core.platform import ( + OSType, + get_cache_dir, + get_config_dir, + get_data_dir, + get_os, + get_terminal_size, + is_linux, + is_macos, + is_windows, + supports_true_color, +) + +__all__ = [ + "get_os", + "get_config_dir", + "get_data_dir", + "get_cache_dir", + "is_windows", + "is_macos", + "is_linux", + "get_terminal_size", + "supports_true_color", + "OSType", +] diff --git a/tcrm_toolkit/interactive/safety.py b/tcrm_toolkit/interactive/safety.py new file mode 100644 index 0000000..8987956 --- /dev/null +++ b/tcrm_toolkit/interactive/safety.py @@ -0,0 +1,330 @@ +"""Connection safety monitor - detects VPN/Proxy that trigger Salesforce blocks.""" + +import asyncio +import json +import os +from contextlib import suppress +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from enum import Enum + +import httpx +import structlog + +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.platform import is_windows + +logger = structlog.get_logger(__name__) + + +class RiskLevel(str, Enum): + SAFE = "safe" + WARNING = "warning" + CRITICAL = "critical" + + +class CheckName(str, Enum): + IP_REPUTATION = "ip_reputation" + VPN_INTERFACES = "vpn_interfaces" + SYSTEM_PROXY = "system_proxy" + DNS_LEAK = "dns_leak" + + +@dataclass +class CheckResult: + name: CheckName + passed: bool + details: str + remediation: str | None = None + risk_level: RiskLevel = RiskLevel.SAFE + + +@dataclass +class SafetyResult: + is_safe: bool = True + checks: dict[CheckName, CheckResult] = field(default_factory=dict) + risk_level: RiskLevel = RiskLevel.SAFE + details: str = "" + timestamp: datetime = field(default_factory=datetime.utcnow) + + def __post_init__(self): + # Determine overall risk level + if any(c.risk_level == RiskLevel.CRITICAL for c in self.checks.values()): + self.risk_level = RiskLevel.CRITICAL + self.is_safe = False + elif any(c.risk_level == RiskLevel.WARNING for c in self.checks.values()): + self.risk_level = RiskLevel.WARNING + self.is_safe = True # Warning doesn't block + else: + self.risk_level = RiskLevel.SAFE + self.is_safe = True + + # Build details string + failed = [c for c in self.checks.values() if not c.passed] + if failed: + self.details = "; ".join(f"{c.name.value}: {c.details}" for c in failed) + + +class SafetyError(Exception): + """Raised when safety check fails and blocking is enabled.""" + pass + + +class SafetyMonitor: + """ + Monitors connection for VPN/Proxy that could trigger Salesforce blocks. + + Salesforce now IMMEDIATELY disables users detected on VPN/Proxy. + This monitor runs on startup and periodically in background. + """ + + VPN_INTERFACE_PREFIXES = ( + "tun", "tap", "wg", "vpn", "wireguard", + "ppp", "ipsec", "sslvpn", "openvpn", + "nordlynx", "proton", "mullvad", "expressvpn", + ) + + IP_API_URL = "https://ipapi.co/json/" + IP_API_TIMEOUT = 10.0 + + def __init__(self, settings: Settings | None = None): + self.settings = settings or get_settings() + self._cache: SafetyResult | None = None + self._cache_expires: datetime | None = None + self._monitor_task: asyncio.Task | None = None + self._http_client: httpx.AsyncClient | None = None + + @property + def http_client(self) -> httpx.AsyncClient: + if self._http_client is None: + self._http_client = httpx.AsyncClient( + timeout=httpx.Timeout(self.IP_API_TIMEOUT), + follow_redirects=True, + ) + return self._http_client + + async def close(self) -> None: + if self._http_client: + await self._http_client.aclose() + self._http_client = None + if self._monitor_task: + self._monitor_task.cancel() + with suppress(asyncio.CancelledError): + await self._monitor_task + + def _is_cache_valid(self) -> bool: + if not self._cache or not self._cache_expires: + return False + return datetime.utcnow() < self._cache_expires + + async def check_connection_safety(self, force: bool = False) -> SafetyResult: + """Run all safety checks and return combined result.""" + if not force and self._is_cache_valid(): + return self._cache + + logger.info("running_safety_checks") + + results = await asyncio.gather( + self._check_ip_reputation(), + self._check_vpn_interfaces(), + self._check_system_proxy(), + self._check_dns_leak(), + return_exceptions=True, + ) + + checks = {} + for i, result in enumerate(results): + check_name = list(CheckName)[i] + if isinstance(result, Exception): + logger.error("safety_check_failed", check=check_name.value, error=str(result)) + checks[check_name] = CheckResult( + name=check_name, + passed=True, + details=f"Check failed: {result}", + risk_level=RiskLevel.SAFE, + ) + else: + checks[check_name] = result + + safety_result = SafetyResult(checks=checks) + self._cache = safety_result + self._cache_expires = datetime.utcnow() + timedelta( + seconds=self.settings.safety_check_interval + ) + + logger.info( + "safety_check_complete", + is_safe=safety_result.is_safe, + risk_level=safety_result.risk_level.value, + details=safety_result.details, + ) + + return safety_result + + async def _check_ip_reputation(self) -> CheckResult: + if self.settings.safety_allowlist_ips: + try: + current_ip = await self._get_current_ip() + if current_ip in self.settings.safety_allowlist_ips: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, + details=f"IP {current_ip} in allowlist", + ) + except Exception: + pass + + try: + response = await self.http_client.get(self.IP_API_URL) + response.raise_for_status() + data = response.json() + + security = data.get("security", {}) + is_vpn = security.get("vpn", False) + is_proxy = security.get("proxy", False) + is_tor = security.get("tor", False) + is_hosting = security.get("hosting", False) + is_relay = security.get("relay", False) + + ip = data.get("ip", "unknown") + country = data.get("country_name", "unknown") + + if is_vpn or is_proxy or is_tor: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=False, + details=f"IP {ip} ({country}): VPN={is_vpn}, Proxy={is_proxy}, Tor={is_tor}", + remediation="Disconnect VPN/Proxy and retry.", + risk_level=RiskLevel.CRITICAL, + ) + + if is_hosting or is_relay: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=False, + details=f"IP {ip} ({country}): Hosting={is_hosting}, Relay={is_relay}", + remediation="Consider using residential IP.", + risk_level=RiskLevel.WARNING, + ) + + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, + details=f"IP {ip} ({country}): Clean", + ) + except httpx.TimeoutException: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, + details="IP reputation check timed out", + risk_level=RiskLevel.SAFE, + ) + except Exception as e: + return CheckResult( + name=CheckName.IP_REPUTATION, + passed=True, + details=f"IP reputation check failed: {e}", + risk_level=RiskLevel.SAFE, + ) + + async def _get_current_ip(self) -> str: + try: + response = await self.http_client.get("https://api.ipify.org?format=json") + response.raise_for_status() + return response.json().get("ip", "unknown") + except Exception: + return "unknown" + + async def _check_vpn_interfaces(self) -> CheckResult: + vpn_interfaces = [] + try: + if is_windows(): + cmd = [ + "powershell", "-Command", + "Get-NetAdapter | Where-Object {$_.InterfaceDescription -match 'VPN|TAP|TUN|WireGuard|OpenVPN'} | Select-Object Name, InterfaceDescription | ConvertTo-Json" + ] + result = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await result.communicate() + if stdout: + adapters = json.loads(stdout.decode()) + if not isinstance(adapters, list): + adapters = [adapters] + for adapter in adapters: + vpn_interfaces.append(adapter.get("Name", "")) + else: + from pathlib import Path as P + net_path = P("/sys/class/net") + if net_path.exists(): + for iface in net_path.iterdir(): + iface_name = iface.name.lower() + if any(iface_name.startswith(prefix) for prefix in self.VPN_INTERFACE_PREFIXES): + vpn_interfaces.append(iface.name) + except Exception as e: + logger.debug("vpn_interface_scan_failed", error=str(e)) + + if vpn_interfaces: + return CheckResult( + name=CheckName.VPN_INTERFACES, + passed=False, + details=f"VPN interfaces detected: {', '.join(vpn_interfaces)}", + remediation="Disconnect VPN and retry.", + risk_level=RiskLevel.CRITICAL, + ) + return CheckResult( + name=CheckName.VPN_INTERFACES, + passed=True, + details="No VPN interfaces detected", + ) + + async def _check_system_proxy(self) -> CheckResult: + proxy_vars = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"] + set_proxies = {var: os.environ.get(var) for var in proxy_vars if os.environ.get(var)} + if set_proxies: + return CheckResult( + name=CheckName.SYSTEM_PROXY, + passed=False, + details=f"System proxy configured: {set_proxies}", + remediation="Disable system proxy.", + risk_level=RiskLevel.WARNING, + ) + return CheckResult( + name=CheckName.SYSTEM_PROXY, + passed=True, + details="No system proxy detected", + ) + + async def _check_dns_leak(self) -> CheckResult: + return CheckResult( + name=CheckName.DNS_LEAK, + passed=True, + details="DNS check passed", + ) + + def start_monitoring(self, callback=None, interval: int | None = None) -> None: + interval = interval or self.settings.safety_check_interval + + async def monitor_loop(): + while True: + try: + await asyncio.sleep(interval) + result = await self.check_connection_safety(force=True) + if callback: + await callback(result) + except asyncio.CancelledError: + break + except Exception as e: + logger.error("safety_monitor_error", error=str(e)) + + try: + loop = asyncio.get_running_loop() + self._monitor_task = loop.create_task(monitor_loop()) + except RuntimeError: + pass + + def stop_monitoring(self) -> None: + if self._monitor_task: + self._monitor_task.cancel() + self._monitor_task = None + diff --git a/tcrm_toolkit/interactive/screens/__init__.py b/tcrm_toolkit/interactive/screens/__init__.py new file mode 100644 index 0000000..38d2d0e --- /dev/null +++ b/tcrm_toolkit/interactive/screens/__init__.py @@ -0,0 +1,16 @@ +"""Interactive screens.""" + +from tcrm_toolkit.interactive.screens.help_screen import HelpScreen +from tcrm_toolkit.interactive.screens.login_screen import LoginScreen +from tcrm_toolkit.interactive.screens.main_screen import MainScreen +from tcrm_toolkit.interactive.screens.org_picker import OrgPickerScreen +from tcrm_toolkit.interactive.screens.safety_modal import SafetyModalScreen + +__all__ = [ + "LoginScreen", + "MainScreen", + "OrgPickerScreen", + "SafetyModalScreen", + "HelpScreen", +] + diff --git a/tcrm_toolkit/interactive/screens/help_screen.py b/tcrm_toolkit/interactive/screens/help_screen.py new file mode 100644 index 0000000..1e3b507 --- /dev/null +++ b/tcrm_toolkit/interactive/screens/help_screen.py @@ -0,0 +1,70 @@ +"""Help screen modal with tabbed content and keyboard shortcuts.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Button, Static, TabbedContent, TabPane + + +class HelpScreen(ModalScreen[None]): + """Modal screen displaying keyboard shortcuts and help.""" + + def compose(self) -> ComposeResult: + with Container(id="help-dialog"): + yield Static("CRM Toolkit - Keyboard Shortcuts & Help", id="help-title") + with TabbedContent(): + with TabPane("Global"): + yield Static( + "\n".join([ + "Ctrl+Q : Quit application", + "Ctrl+P : Open command palette", + "Ctrl+O : Organization picker (switch org)", + "Ctrl+R : Refresh current view", + "F1 : Help screen", + "Escape : Back / Cancel / Clear search", + ]) + ) + with TabPane("Navigation"): + yield Static( + "\n".join([ + "Tab / Shift+Tab : Move between panels", + "Arrow Up / Down / j / k : Navigate lists", + "Page Up / Page Down : Scroll pages", + "Home / End : Start/End of list", + "Enter : Select / Activate item", + ]) + ) + with TabPane("Data Browsers"): + yield Static( + "\n".join([ + "/ : Focus search input", + "Escape : Clear search", + "Enter : Apply search filter", + "Click Header : Sort column", + ]) + ) + with TabPane("Actions"): + yield Static( + "\n".join([ + "E : Extract dataset", + "U : Upload dataset", + "B : Backup dashboard", + "R : Restore dashboard", + "S : Start dataflow", + "T : Stop dataflow", + "Y : Show dependencies", + "D : Delete item (with confirmation)", + "C : Copy ID to clipboard", + ]) + ) + yield Button("Close", variant="primary", id="close-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "close-btn": + self.dismiss(None) + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss(None) diff --git a/tcrm_toolkit/interactive/screens/login_screen.py b/tcrm_toolkit/interactive/screens/login_screen.py new file mode 100644 index 0000000..72cfdcd --- /dev/null +++ b/tcrm_toolkit/interactive/screens/login_screen.py @@ -0,0 +1,118 @@ +"""Login screen for initial authentication.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Static + +from tcrm_toolkit.interactive.session import SessionManager + + +class LoginScreen(ModalScreen[bool]): + """Modal screen for SF CLI web login.""" + + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ] + + def __init__(self, session: SessionManager): + super().__init__() + self.session = session + self._alias = "default" + self._instance_url = None + + def compose(self) -> ComposeResult: + yield Container( + Vertical( + Static("🔐 Salesforce Authentication", id="login-title"), + Static( + "Choose authentication method:\n" + "• Browser Login: Opens local browser (desktop environments).\n" + "• Device Code Login: Generates code & URL for headless VMs / SSH sessions.", + id="login-info" + ), + Input(placeholder="Org alias (default)", id="alias-input", value="default"), + Input(placeholder="Custom instance URL (optional)", id="instance-input"), + Static("", id="login-status"), + Button("Login with Browser (Web)", id="login-btn", variant="primary"), + Button("Login with Device Code (Headless)", id="device-login-btn", variant="warning"), + Button("Cancel", id="cancel-btn", variant="default"), + id="login-form" + ), + id="login-container" + ) + + @on(Button.Pressed, "#login-btn") + async def on_login_pressed(self) -> None: + alias_input = self.query_one("#alias-input", Input) + instance_input = self.query_one("#instance-input", Input) + status = self.query_one("#login-status", Static) + login_btn = self.query_one("#login-btn", Button) + device_btn = self.query_one("#device-login-btn", Button) + + self._alias = alias_input.value or "default" + self._instance_url = instance_input.value or None + + login_btn.disabled = True + device_btn.disabled = True + status.update("🔄 Opening browser for authentication...") + + try: + await self._run_login() + self.dismiss(True) + except Exception as e: + status.update(f"❌ Login failed: {e}") + login_btn.disabled = False + device_btn.disabled = False + + @on(Button.Pressed, "#device-login-btn") + async def on_device_login_pressed(self) -> None: + alias_input = self.query_one("#alias-input", Input) + instance_input = self.query_one("#instance-input", Input) + status = self.query_one("#login-status", Static) + login_btn = self.query_one("#login-btn", Button) + device_btn = self.query_one("#device-login-btn", Button) + + self._alias = alias_input.value or "default" + self._instance_url = instance_input.value or None + + login_btn.disabled = True + device_btn.disabled = True + status.update("🔄 Requesting device code... Check terminal output for URL/code.") + + try: + await self._run_device_login() + self.dismiss(True) + except Exception as e: + status.update(f"❌ Device login failed: {e}") + login_btn.disabled = False + device_btn.disabled = False + + async def _run_login(self) -> None: + status = self.query_one("#login-status", Static) + + token = await self.session.login( + alias=self._alias, + instance_url=self._instance_url, + ) + + status.update(f"✅ Authenticated as {self.session.current_org.username if self.session.current_org else 'user'}") + + async def _run_device_login(self) -> None: + status = self.query_one("#login-status", Static) + + token = await self.session.login_device( + alias=self._alias, + instance_url=self._instance_url, + ) + + status.update(f"✅ Authenticated via device flow as {self.session.current_org.username if self.session.current_org else 'user'}") + + @on(Button.Pressed, "#cancel-btn") + def on_cancel_pressed(self) -> None: + self.dismiss(False) + + def action_cancel(self) -> None: + self.dismiss(False) + diff --git a/tcrm_toolkit/interactive/screens/main_screen.py b/tcrm_toolkit/interactive/screens/main_screen.py new file mode 100644 index 0000000..91ee0d4 --- /dev/null +++ b/tcrm_toolkit/interactive/screens/main_screen.py @@ -0,0 +1,215 @@ +"""Main screen with sidebar navigation and content area.""" + +from typing import Any + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import DataTable, Label, ListItem, ListView, Static + +from tcrm_toolkit.interactive.safety import SafetyMonitor +from tcrm_toolkit.interactive.screens.help_screen import HelpScreen +from tcrm_toolkit.interactive.session import SessionManager +from tcrm_toolkit.interactive.widgets.data_table import DataBrowser +from tcrm_toolkit.interactive.widgets.detail_panel import DetailPanel + + +class MainScreen(Vertical): + """Main screen with navigation sidebar and content area.""" + + BINDINGS = [ + ("ctrl+p", "command_palette", "Command Palette"), + ("ctrl+o", "org_picker", "Switch Org"), + ("ctrl+r", "refresh", "Refresh"), + ("escape", "escape", "Back"), + ] + + def __init__(self, session: SessionManager, safety: SafetyMonitor, task_runner: Any | None = None): + super().__init__() + self.session = session + self.safety = safety + self.task_runner = task_runner + self._current_view = "datasets" + + def compose(self) -> ComposeResult: + yield Horizontal( + Vertical( + Static("📊 TCRM Toolkit", id="sidebar-title"), + ListView( + ListItem(Label("📊 Datasets"), id="nav-datasets"), + ListItem(Label("📈 Dashboards"), id="nav-dashboards"), + ListItem(Label("🔄 Dataflows"), id="nav-dataflows"), + ListItem(Label("📋 Jobs"), id="nav-jobs"), + ListItem(Label("🔐 Orgs"), id="nav-orgs"), + ListItem(Label("⚙️ Config"), id="nav-config"), + ListItem(Label("📋 History"), id="nav-history"), + id="nav-list" + ), + Static("[dim]Ctrl+P: Commands Ctrl+O: Orgs Ctrl+R: Refresh[/dim]", id="sidebar-hints"), + id="sidebar" + ), + Vertical( + Static("Select a navigation item", id="content-title"), + Container(id="content-area"), + id="content" + ), + DetailPanel(id="detail-panel"), + id="main-layout" + ) + + async def on_mount(self) -> None: + nav_list = self.query_one("#nav-list", ListView) + nav_list.index = 0 + await self._switch_view("datasets") + + @on(ListView.Selected, "#nav-list") + async def on_nav_selected(self, event: ListView.Selected) -> None: + item = event.item + if item.id and item.id.startswith("nav-"): + view = item.id[4:] + await self._switch_view(view) + + async def _switch_view(self, view: str) -> None: + self._current_view = view + titles = { + "datasets": "📊 Datasets", + "dashboards": "📈 Dashboards", + "dataflows": "🔄 Dataflows", + "jobs": "📋 Dataflow Jobs", + "orgs": "🔐 Organizations", + "config": "⚙️ Configuration", + "history": "📋 Task History", + } + self.query_one("#content-title", Static).update(titles.get(view, view)) + await self._load_view(view) + + async def _load_view(self, view: str) -> None: + container = self.query_one("#content-area", Container) + await container.remove_children() + + if view == "datasets": + await self._load_datasets_view(container) + elif view == "dashboards": + await self._load_dashboards_view(container) + elif view == "dataflows": + await self._load_dataflows_view(container) + elif view == "jobs": + await self._load_jobs_view(container) + elif view == "orgs": + await self._load_orgs_view(container) + elif view == "config": + await self._load_config_view(container) + elif view == "history": + await self._load_history_view(container) + + async def _load_datasets_view(self, container: Container) -> None: + """Load datasets browser.""" + from tcrm_toolkit.interactive.operations.dataset_ops import create_dataset_browser + browser = create_dataset_browser(self.session) + await container.mount(browser) + + async def _load_dashboards_view(self, container: Container) -> None: + """Load dashboards browser.""" + from tcrm_toolkit.interactive.operations.dashboard_ops import create_dashboard_browser + browser = create_dashboard_browser(self.session) + await container.mount(browser) + + async def _load_dataflows_view(self, container: Container) -> None: + """Load dataflows browser.""" + from tcrm_toolkit.interactive.operations.dataflow_ops import create_dataflow_browser + browser = create_dataflow_browser(self.session) + await container.mount(browser) + + async def _load_jobs_view(self, container: Container) -> None: + """Load jobs browser with auto-refresh.""" + from tcrm_toolkit.interactive.operations.dataflow_ops import create_dataflow_job_browser + browser = create_dataflow_job_browser(self.session) + await container.mount(browser) + + @on(DataBrowser.RowSelected) + async def on_data_browser_row_selected(self, event: DataBrowser.RowSelected) -> None: + """Handle row selection from any data browser.""" + detail = self.query_one("#detail-panel", DetailPanel) + if self._current_view == "datasets": + detail.show_dataset(event.row) + elif self._current_view == "dashboards": + detail.show_dashboard(event.row) + elif self._current_view == "dataflows": + detail.show_dataflow(event.row) + elif self._current_view == "jobs": + detail.show_dataflow_job(event.row) + + async def _load_orgs_view(self, container: Container) -> None: + orgs = self.session.list_orgs() + table = DataTable(id="orgs-table", cursor_type="row") + table.add_columns("#", "Alias", "Username", "Instance URL", "Current") + table.zebra_stripes = True + await container.mount(table) + + for i, org in enumerate(orgs, 1): + current = "●" if org.alias == self.session.current_alias else "" + table.add_row(str(i), org.alias, org.username or "N/A", org.instance_url, current) + + async def _load_config_view(self, container: Container) -> None: + from tcrm_toolkit.interactive.config_manager import ConfigManager + cfg = ConfigManager().load() + table = DataTable(id="config-table", cursor_type="row") + table.add_column("Setting", style="cyan") + table.add_column("Value", style="white") + table.zebra_stripes = True + await container.mount(table) + + for field_name, value in cfg.model_dump().items(): + table.add_row(field_name, str(value)) + + async def _load_history_view(self, container: Container) -> None: + from tcrm_toolkit.interactive.widgets.task_history import TaskHistory + from tcrm_toolkit.interactive.tasks import TaskRunner + runner = self.task_runner or TaskRunner() + history_widget = TaskHistory(runner) + await container.mount(history_widget) + + async def refresh_data(self) -> None: + await self._load_view(self._current_view) + + async def action_escape(self) -> None: + detail = self.query_one("#detail-panel", DetailPanel) + detail.clear() + + async def action_command_palette(self) -> None: + from tcrm_toolkit.interactive.widgets.command_palette import CommandPaletteScreen + from tcrm_toolkit.interactive.screens.help_screen import HelpScreen + + commands = [ + ("📊 Datasets", "view-datasets"), + ("📈 Dashboards", "view-dashboards"), + ("🔄 Dataflows", "view-dataflows"), + ("📋 Dataflow Jobs", "view-jobs"), + ("🔐 Organizations", "view-orgs"), + ("⚙️ Configuration", "view-config"), + ("📋 Task History", "view-history"), + ("🔄 Refresh View", "refresh"), + ("🔐 Switch Organization", "org_picker"), + ("❓ Help & Shortcuts", "help"), + ("🚪 Quit", "quit"), + ] + + def on_command_selected(action_id: str | None) -> None: + if action_id: + self.run_worker(self._handle_command(action_id)) + + self.app.push_screen(CommandPaletteScreen(commands), on_command_selected) + + async def _handle_command(self, action_id: str) -> None: + if action_id.startswith("view-"): + view = action_id[5:] + await self._switch_view(view) + elif action_id == "refresh": + await self.app.action_refresh() + elif action_id == "org_picker": + await self.app.action_org_picker() + elif action_id == "help": + self.app.push_screen(HelpScreen()) + elif action_id == "quit": + self.app.exit() + diff --git a/tcrm_toolkit/interactive/screens/org_picker.py b/tcrm_toolkit/interactive/screens/org_picker.py new file mode 100644 index 0000000..c6f115a --- /dev/null +++ b/tcrm_toolkit/interactive/screens/org_picker.py @@ -0,0 +1,69 @@ +"""Org picker screen for quick org switching.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label, ListItem, ListView, Static + +from tcrm_toolkit.interactive.session import OrgSession + + +class OrgPickerScreen(ModalScreen[str]): + """Modal screen for picking org alias.""" + + BINDINGS = [ + ("escape", "cancel", "Cancel"), + ("enter", "select", "Select"), + ] + + def __init__(self, orgs: list[OrgSession], current_alias: str): + super().__init__() + self.orgs = orgs + self.current_alias = current_alias + + def compose(self) -> ComposeResult: + yield Container( + Vertical( + Static("🔐 Switch Organization", id="picker-title"), + ListView( + *[ + ListItem( + Label( + f"{'● ' if org.alias == self.current_alias else ' '}" + f"{org.alias} — {org.username} ({org.instance_url})" + ), + id=f"org-{org.alias}", + ) + for org in self.orgs + ], + id="org-list" + ), + Button("Cancel", id="cancel-btn"), + id="picker-container" + ), + id="picker-dialog" + ) + + @on(ListView.Selected, "#org-list") + def on_org_selected(self, event: ListView.Selected) -> None: + item = event.item + if item.id and item.id.startswith("org-"): + alias = item.id[4:] + self.dismiss(alias) + + @on(Button.Pressed, "#cancel-btn") + def on_cancel(self) -> None: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_select(self) -> None: + list_view = self.query_one("#org-list", ListView) + if list_view.highlighted_child: + item = list_view.highlighted_child + if item.id and item.id.startswith("org-"): + alias = item.id[4:] + self.dismiss(alias) + diff --git a/tcrm_toolkit/interactive/screens/safety_modal.py b/tcrm_toolkit/interactive/screens/safety_modal.py new file mode 100644 index 0000000..1fc3dbc --- /dev/null +++ b/tcrm_toolkit/interactive/screens/safety_modal.py @@ -0,0 +1,65 @@ +"""Safety modal for critical VPN/Proxy detection with hard block.""" + +from __future__ import annotations + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Static + +from tcrm_toolkit.interactive.safety import RiskLevel, SafetyResult + + +class SafetyModalScreen(ModalScreen[str]): + """Modal dialog for critical safety alerts with hard block.""" + + BINDINGS = [ + ("escape", "cancel", "Quit"), + ] + + def __init__(self, safety_result: SafetyResult): + super().__init__() + self.safety_result = safety_result + + def compose(self) -> ComposeResult: + details_lines = [] + for check in self.safety_result.checks.values(): + if not check.passed: + icon = "🔴" if check.risk_level == RiskLevel.CRITICAL else "🟡" + details_lines.append(f"{icon} {check.name.value}: {check.details}") + if check.remediation: + details_lines.append(f" → {check.remediation}") + + details_text = "\n".join(details_lines) if details_lines else "Critical security risk detected" + + yield Container( + Vertical( + Static("⚠️ CRITICAL CONNECTION SAFETY ALERT", id="safety-title"), + Static( + "Salesforce immediately disables users detected on VPN/Proxy.\n" + "All API operations are strictly blocked until your connection is secure.", + id="safety-warning" + ), + Static(details_text, id="safety-details"), + Container( + Button("Disconnect VPN & Retry", id="retry-btn", variant="primary"), + Button("Quit", id="quit-btn", variant="error"), + id="safety-buttons" + ), + id="safety-container" + ), + id="safety-dialog" + ) + + @on(Button.Pressed, "#retry-btn") + def on_retry(self) -> None: + self.dismiss("retry") + + @on(Button.Pressed, "#quit-btn") + def on_quit(self) -> None: + self.dismiss("quit") + + def action_cancel(self) -> None: + self.dismiss("quit") + diff --git a/tcrm_toolkit/interactive/session.py b/tcrm_toolkit/interactive/session.py new file mode 100644 index 0000000..9563880 --- /dev/null +++ b/tcrm_toolkit/interactive/session.py @@ -0,0 +1,232 @@ +"""Session management for Interactive TUI - wraps SFCLIAuthService.""" + +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from tcrm_toolkit.core.auth import SFCLIAuthError, SFCLIAuthService +from tcrm_toolkit.core.client import SalesforceClient +from tcrm_toolkit.core.config import Settings, get_settings +from tcrm_toolkit.core.crypto import CryptoManager, create_crypto_manager +from tcrm_toolkit.interactive.safety import SafetyError, SafetyMonitor + + +@dataclass +class OrgSession: + """Represents an authenticated org session.""" + alias: str + username: str | None + instance_url: str + is_default: bool = False + + +class SessionManager: + """ + Manages authenticated sessions across the TUI lifecycle. + + Responsibilities: + - Multi-org credential management via SF CLI aliases + - Auto-refresh tokens before expiry + - Session persistence across TUI restarts + - Quick org switching (Ctrl+O) + - Safety gate: blocks client creation if VPN/Proxy detected + """ + + def __init__( + self, + settings: Settings | None = None, + crypto: CryptoManager | None = None, + safety_monitor: SafetyMonitor | None = None, + ): + self.settings = settings or get_settings() + self.crypto = crypto or create_crypto_manager() + self.safety = safety_monitor or SafetyMonitor(self.settings) + self._auth_service: SFCLIAuthService | None = None + self._current_alias: str = "default" + self._client: SalesforceClient | None = None + self._org_sessions: dict[str, OrgSession] = {} + + @property + def auth_service(self) -> SFCLIAuthService: + """Lazy-initialize SFCLIAuthService.""" + if self._auth_service is None: + self._auth_service = SFCLIAuthService( + settings=self.settings, + crypto_manager=self.crypto, + ) + return self._auth_service + + @property + def current_alias(self) -> str: + return self._current_alias + + @property + def current_org(self) -> OrgSession | None: + return self._org_sessions.get(self._current_alias) + + async def initialize(self) -> None: + """Initialize session - load orgs, check safety, check for existing tokens (no auto-login).""" + safety_result = await self.safety.check_connection_safety() + if not safety_result.is_safe and self.settings.safety_block_on_critical: + raise SafetyError(f"Unsafe connection: {safety_result.details}") + + await self.refresh_org_list() + + # Check for existing valid token without triggering auto-login + try: + await self.ensure_valid_token(auto_refresh=False) + except SFCLIAuthError: + pass + + async def refresh_org_list(self) -> list[OrgSession]: + """Refresh list of authorized orgs from SF CLI and token store.""" + orgs = [] + try: + orgs = await self.auth_service.list_orgs() + except Exception: + pass + + for org in orgs: + alias = org.get("alias") or org.get("username", "unknown") + username = org.get("username") + instance_url = org.get("instanceUrl", "") + + if username and instance_url: + self._org_sessions[alias] = OrgSession( + alias=alias, + username=username, + instance_url=instance_url.rstrip("/"), + is_default=(alias == "default"), + ) + + # Ensure current alias and default are in org sessions if tokens exist in token store + for alias in [self._current_alias, "default"]: + if alias not in self._org_sessions: + try: + stored = await self.auth_service.token_store.load_token(alias) + if stored and stored.username and stored.instance_url: + self._org_sessions[alias] = OrgSession( + alias=alias, + username=stored.username, + instance_url=stored.instance_url.rstrip("/"), + is_default=(alias == "default"), + ) + except Exception: + pass + + return list(self._org_sessions.values()) + + async def ensure_valid_token(self, alias: str | None = None, auto_refresh: bool = False) -> str: + """Get valid access token for alias, auto-refresh if needed.""" + alias = alias or self._current_alias + + safety_result = await self.safety.check_connection_safety() + if not safety_result.is_safe and self.settings.safety_block_on_critical: + raise SafetyError(f"Unsafe connection: {safety_result.details}") + + token = await self.auth_service.get_access_token(alias=alias, auto_refresh=auto_refresh) + return token + + async def get_client(self, alias: str | None = None) -> SalesforceClient: + """Get authenticated SalesforceClient for alias.""" + alias = alias or self._current_alias + + safety_result = await self.safety.check_connection_safety() + if not safety_result.is_safe and self.settings.safety_block_on_critical: + raise SafetyError(f"Unsafe connection: {safety_result.details}") + + access_token = await self.ensure_valid_token(alias) + instance_url = await self.auth_service.get_instance_url(alias) + + self._client = SalesforceClient( + access_token=access_token, + instance_url=instance_url, + settings=self.settings, + ) + + return self._client + + @asynccontextmanager + async def client_context(self, alias: str | None = None): + """Context manager for SalesforceClient with auto-cleanup.""" + client = await self.get_client(alias) + try: + yield client + finally: + await client.close() + self._client = None + + async def switch_org(self, alias: str) -> OrgSession: + """Switch to different org alias.""" + if alias not in self._org_sessions: + await self.refresh_org_list() + + if alias not in self._org_sessions: + raise SFCLIAuthError(f"Org alias '{alias}' not found. Run 'sf org list' first.") + + self._current_alias = alias + self._client = None + + await self.ensure_valid_token(alias) + + return self._org_sessions[alias] + + async def login(self, alias: str = "default", instance_url: str | None = None) -> str: + """Run SF CLI web login flow.""" + token = await self.auth_service.login(alias=alias, instance_url=instance_url) + self._current_alias = alias + await self.refresh_org_list() + if alias not in self._org_sessions: + stored = await self.auth_service.token_store.load_token(alias) + if stored and stored.username and stored.instance_url: + self._org_sessions[alias] = OrgSession( + alias=alias, + username=stored.username, + instance_url=stored.instance_url.rstrip("/"), + is_default=(alias == "default"), + ) + return token + + async def login_device(self, alias: str = "default", instance_url: str | None = None) -> str: + """Run SF CLI device login flow for headless environments.""" + token = await self.auth_service.login_device(alias=alias, instance_url=instance_url) + self._current_alias = alias + await self.refresh_org_list() + if alias not in self._org_sessions: + stored = await self.auth_service.token_store.load_token(alias) + if stored and stored.username and stored.instance_url: + self._org_sessions[alias] = OrgSession( + alias=alias, + username=stored.username, + instance_url=stored.instance_url.rstrip("/"), + is_default=(alias == "default"), + ) + return token + + async def logout(self, alias: str = "default") -> bool: + """Logout and remove stored auth for alias.""" + result = await self.auth_service.logout(alias) + await self.refresh_org_list() + + if alias == self._current_alias: + self._current_alias = "default" + self._client = None + + return result + + async def get_status(self, alias: str | None = None) -> dict: + """Get authentication status for alias.""" + alias = alias or self._current_alias + return await self.auth_service.status(alias) + + def list_orgs(self) -> list[OrgSession]: + """List all known org sessions.""" + return list(self._org_sessions.values()) + + async def close(self) -> None: + """Cleanup resources.""" + if self._client: + await self._client.close() + self._client = None + if self._auth_service: + await self._auth_service.close() + self._auth_service = None diff --git a/tcrm_toolkit/interactive/styles/dark.css b/tcrm_toolkit/interactive/styles/dark.css new file mode 100644 index 0000000..d344059 --- /dev/null +++ b/tcrm_toolkit/interactive/styles/dark.css @@ -0,0 +1,292 @@ +/* Dark theme - self-contained stylesheet */ +$background: #0c0c0c; +$surface: #1e1e1e; +$surface-darken-1: #252525; +$surface-darken-2: #2d2d2d; +$surface-lighten-1: #262626; +$surface-lighten-2: #323232; +$primary: #007acc; +$primary-darken-2: #005a9e; +$primary-darken-3: #004780; +$secondary: #6a9955; +$success: #6a9955; +$success-darken-2: #4a7a3d; +$warning: #d7ba7d; +$warning-darken-2: #b89d5a; +$error: #f44747; +$error-darken-2: #c03030; +$text: #d4d4d4; +$text-muted: #858585; +$accent: #4ec9b0; + +Screen { + background: $surface; + color: $text; +} + +#main-container, MainScreen, #main-layout { + width: 100%; + height: 100%; +} + +Header { + background: $primary; + color: $text; + dock: top; + height: 3; +} + +Footer { + background: $primary-darken-2; + color: $text; + dock: bottom; + height: 1; +} + +Static#title { + text-style: bold; + color: $accent; + text-align: center; +} + +Static#status-bar { + background: $surface-lighten-2; + color: $text-muted; + height: 1; + dock: bottom; + padding: 0 1; +} + +Static#sidebar-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +ListView#nav-list { + background: $surface-darken-1; + width: 25; +} + +ListView#nav-list > ListItem { + padding: 1 2; +} + +ListView#nav-list > ListItem.--highlight { + background: $primary; + color: $text; +} + +ListView#nav-list > ListItem:hover { + background: $primary-darken-2; +} + +Container#sidebar { + width: 25; + background: $surface-darken-1; + border-right: solid $primary-darken-3; +} + +Container#content { + width: 1fr; +} + +Container#detail-panel { + width: 30; + border-left: solid $primary-darken-3; + background: $surface-darken-1; +} + +DataTable { + background: $surface; + color: $text; +} + +DataTable > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +DataTable > .datatable--cursor { + background: $primary; + color: $text; +} + +DataTable > .datatable--row--odd { + background: $surface-darken-1; +} + +DataTable > .datatable--row--even { + background: $surface; +} + +Label#detail-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +Static#detail-content { + padding: 1 2; + height: 1fr; + overflow: auto; +} + +ProgressBar { + color: $success; + background: $surface-darken-1; +} + +ProgressBar > .progress-bar--complete { + color: $success; +} + +ProgressBar > .progress-bar--remaining { + color: $surface-darken-2; +} + +Button { + margin: 1 2; + min-width: 10; +} + +Button.--primary { + background: $success; + color: $text; +} + +Button.--primary:hover { + background: $success-darken-2; +} + +Button.--warning { + background: $warning; + color: $text; +} + +Button.--warning:hover { + background: $warning-darken-2; +} + +Button.--error { + background: $error; + color: $text; +} + +Button.--error:hover { + background: $error-darken-2; +} + +Input { + margin: 1 2; + min-width: 20; +} + +Input:focus { + border: tall $primary; +} + +ModalScreen { + align: center middle; +} + +#login-container, #picker-dialog, #safety-dialog, #palette-dialog, #help-dialog { + background: $surface; + border: thick $primary; + padding: 2 4; + width: 60; + height: auto; +} + +#login-title, #picker-title, #safety-title, #palette-title, #help-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; +} + +#login-info, #safety-warning, #safety-details { + color: $text-muted; + text-align: center; + margin: 1 0; +} + +#safety-title { + color: $error; + text-style: bold; +} + +#safety-warning { + color: $warning; +} + +Checkbox { + margin: 1 2; +} + +#safety-buttons { + height: 3; + align: center middle; +} + +#safety-buttons > Button { + margin: 0 1; + min-width: 20; +} + +#status-bar { + layout: horizontal; + overflow: hidden; +} + +#status-bar > Static { + margin: 0 1; +} + +#browser-header { + height: 3; + padding: 1 2; +} + +#browser-title { + text-style: bold; + color: $primary; +} + +#search-input { + width: 30; +} + +#pagination-info { + color: $text-muted; +} + +#browser-status { + color: $warning; + text-align: center; +} + +#history-title, #progress-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +#history-table, #progress-table { + background: $surface; + color: $text; +} + +#history-table > .datatable--header, +#progress-table > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +#history-table > .datatable--cursor, +#progress-table > .datatable--cursor { + background: $primary; + color: $text; +} diff --git a/tcrm_toolkit/interactive/styles/default.css b/tcrm_toolkit/interactive/styles/default.css new file mode 100644 index 0000000..a5784c8 --- /dev/null +++ b/tcrm_toolkit/interactive/styles/default.css @@ -0,0 +1,272 @@ +/* Default Textual stylesheet for CRM Toolkit */ +Screen { + background: $surface; + color: $text; +} + +#main-container, MainScreen, #main-layout { + width: 100%; + height: 100%; +} + +Header { + background: $primary; + color: $text; + dock: top; + height: 3; +} + +Footer { + background: $primary-darken-2; + color: $text; + dock: bottom; + height: 1; +} + +Static#title { + text-style: bold; + color: $accent; + text-align: center; +} + +Static#status-bar { + background: $surface-lighten-2; + color: $text-muted; + height: 1; + dock: bottom; + padding: 0 1; +} + +Static#sidebar-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +ListView#nav-list { + background: $surface-darken-1; + width: 25; +} + +ListView#nav-list > ListItem { + padding: 1 2; +} + +ListView#nav-list > ListItem.--highlight { + background: $primary; + color: $text; +} + +ListView#nav-list > ListItem:hover { + background: $primary-darken-2; +} + +Container#sidebar { + width: 25; + background: $surface-darken-1; + border-right: solid $primary-darken-3; +} + +Container#content { + width: 1fr; +} + +Container#detail-panel { + width: 30; + border-left: solid $primary-darken-3; + background: $surface-darken-1; +} + +DataTable { + background: $surface; + color: $text; +} + +DataTable > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +DataTable > .datatable--cursor { + background: $primary; + color: $text; +} + +DataTable > .datatable--row--odd { + background: $surface-darken-1; +} + +DataTable > .datatable--row--even { + background: $surface; +} + +Label#detail-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +Static#detail-content { + padding: 1 2; + height: 1fr; + overflow: auto; +} + +ProgressBar { + color: $success; + background: $surface-darken-1; +} + +ProgressBar > .progress-bar--complete { + color: $success; +} + +ProgressBar > .progress-bar--remaining { + color: $surface-darken-2; +} + +Button { + margin: 1 2; + min-width: 10; +} + +Button.--primary { + background: $success; + color: $text; +} + +Button.--primary:hover { + background: $success-darken-2; +} + +Button.--warning { + background: $warning; + color: $text; +} + +Button.--warning:hover { + background: $warning-darken-2; +} + +Button.--error { + background: $error; + color: $text; +} + +Button.--error:hover { + background: $error-darken-2; +} + +Input { + margin: 1 2; + min-width: 20; +} + +Input:focus { + border: tall $primary; +} + +ModalScreen { + align: center middle; +} + +#login-container, #picker-dialog, #safety-dialog, #palette-dialog, #help-dialog { + background: $surface; + border: thick $primary; + padding: 2 4; + width: 60; + height: auto; +} + +#login-title, #picker-title, #safety-title, #palette-title, #help-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; +} + +#login-info, #safety-warning, #safety-details { + color: $text-muted; + text-align: center; + margin: 1 0; +} + +#safety-title { + color: $error; + text-style: bold; +} + +#safety-warning { + color: $warning; +} + +Checkbox { + margin: 1 2; +} + +#safety-buttons { + height: 3; + align: center middle; +} + +#safety-buttons > Button { + margin: 0 1; + min-width: 20; +} + +#status-bar { + layout: horizontal; + overflow: hidden; +} + +#status-bar > Static { + margin: 0 1; +} + +#browser-header { + height: 3; + padding: 1 2; +} + +#browser-title { + text-style: bold; + color: $primary; +} + +#search-input { + width: 30; +} + +#pagination-info { + color: $text-muted; +} + +#browser-status { + color: $warning; + text-align: center; +} + +#history-title, #progress-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +#history-table, #progress-table { + background: $surface; + color: $text; +} + +#history-table > .datatable--header, +#progress-table > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +#history-table > .datatable--cursor, +#progress-table > .datatable--cursor { + background: $primary; + color: $text; +} diff --git a/tcrm_toolkit/interactive/styles/light.css b/tcrm_toolkit/interactive/styles/light.css new file mode 100644 index 0000000..139a14c --- /dev/null +++ b/tcrm_toolkit/interactive/styles/light.css @@ -0,0 +1,292 @@ +/* Light theme - self-contained stylesheet */ +$background: #ffffff; +$surface: #f8f8f8; +$surface-darken-1: #eeeeee; +$surface-darken-2: #e0e0e0; +$surface-lighten-1: #ffffff; +$surface-lighten-2: #f2f2f2; +$primary: #0066cc; +$primary-darken-2: #004c99; +$primary-darken-3: #003366; +$secondary: #006600; +$success: #006600; +$success-darken-2: #004c00; +$warning: #cc9900; +$warning-darken-2: #a67a00; +$error: #cc0000; +$error-darken-2: #a60000; +$text: #222222; +$text-muted: #666666; +$accent: #009900; + +Screen { + background: $surface; + color: $text; +} + +#main-container, MainScreen, #main-layout { + width: 100%; + height: 100%; +} + +Header { + background: $primary; + color: $text; + dock: top; + height: 3; +} + +Footer { + background: $primary-darken-2; + color: $text; + dock: bottom; + height: 1; +} + +Static#title { + text-style: bold; + color: $accent; + text-align: center; +} + +Static#status-bar { + background: $surface-lighten-2; + color: $text-muted; + height: 1; + dock: bottom; + padding: 0 1; +} + +Static#sidebar-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +ListView#nav-list { + background: $surface-darken-1; + width: 25; +} + +ListView#nav-list > ListItem { + padding: 1 2; +} + +ListView#nav-list > ListItem.--highlight { + background: $primary; + color: $text; +} + +ListView#nav-list > ListItem:hover { + background: $primary-darken-2; +} + +Container#sidebar { + width: 25; + background: $surface-darken-1; + border-right: solid $primary-darken-3; +} + +Container#content { + width: 1fr; +} + +Container#detail-panel { + width: 30; + border-left: solid $primary-darken-3; + background: $surface-darken-1; +} + +DataTable { + background: $surface; + color: $text; +} + +DataTable > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +DataTable > .datatable--cursor { + background: $primary; + color: $text; +} + +DataTable > .datatable--row--odd { + background: $surface-darken-1; +} + +DataTable > .datatable--row--even { + background: $surface; +} + +Label#detail-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +Static#detail-content { + padding: 1 2; + height: 1fr; + overflow: auto; +} + +ProgressBar { + color: $success; + background: $surface-darken-1; +} + +ProgressBar > .progress-bar--complete { + color: $success; +} + +ProgressBar > .progress-bar--remaining { + color: $surface-darken-2; +} + +Button { + margin: 1 2; + min-width: 10; +} + +Button.--primary { + background: $success; + color: $text; +} + +Button.--primary:hover { + background: $success-darken-2; +} + +Button.--warning { + background: $warning; + color: $text; +} + +Button.--warning:hover { + background: $warning-darken-2; +} + +Button.--error { + background: $error; + color: $text; +} + +Button.--error:hover { + background: $error-darken-2; +} + +Input { + margin: 1 2; + min-width: 20; +} + +Input:focus { + border: tall $primary; +} + +ModalScreen { + align: center middle; +} + +#login-container, #picker-dialog, #safety-dialog, #palette-dialog, #help-dialog { + background: $surface; + border: thick $primary; + padding: 2 4; + width: 60; + height: auto; +} + +#login-title, #picker-title, #safety-title, #palette-title, #help-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; +} + +#login-info, #safety-warning, #safety-details { + color: $text-muted; + text-align: center; + margin: 1 0; +} + +#safety-title { + color: $error; + text-style: bold; +} + +#safety-warning { + color: $warning; +} + +Checkbox { + margin: 1 2; +} + +#safety-buttons { + height: 3; + align: center middle; +} + +#safety-buttons > Button { + margin: 0 1; + min-width: 20; +} + +#status-bar { + layout: horizontal; + overflow: hidden; +} + +#status-bar > Static { + margin: 0 1; +} + +#browser-header { + height: 3; + padding: 1 2; +} + +#browser-title { + text-style: bold; + color: $primary; +} + +#search-input { + width: 30; +} + +#pagination-info { + color: $text-muted; +} + +#browser-status { + color: $warning; + text-align: center; +} + +#history-title, #progress-title { + text-style: bold; + color: $primary; + padding: 1 0; +} + +#history-table, #progress-table { + background: $surface; + color: $text; +} + +#history-table > .datatable--header, +#progress-table > .datatable--header { + background: $primary-darken-2; + color: $text; + text-style: bold; +} + +#history-table > .datatable--cursor, +#progress-table > .datatable--cursor { + background: $primary; + color: $text; +} diff --git a/tcrm_toolkit/interactive/tasks.py b/tcrm_toolkit/interactive/tasks.py new file mode 100644 index 0000000..1fcdd89 --- /dev/null +++ b/tcrm_toolkit/interactive/tasks.py @@ -0,0 +1,316 @@ +"""Background task runner with progress tracking and history.""" + +import asyncio +import os +import uuid +from collections.abc import Callable +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + +import structlog +from textual.message import Message +from textual.widget import Widget + +logger = structlog.get_logger(__name__) + + +class TaskStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class TaskProgress: + """Progress update for a task.""" + task_id: str + status: TaskStatus + current: int = 0 + total: int = 0 + message: str = "" + details: dict = field(default_factory=dict) + started_at: datetime = field(default_factory=datetime.utcnow) + completed_at: datetime | None = None + error: str | None = None + + @property + def percent(self) -> float: + if self.total == 0: + return 0.0 + return min(100.0, (self.current / self.total) * 100) + + @property + def is_finished(self) -> bool: + return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED) + + +@dataclass +class TaskResult: + """Final result of a task.""" + task_id: str + status: TaskStatus + result: Any = None + error: str | None = None + started_at: datetime = field(default_factory=datetime.utcnow) + completed_at: datetime = field(default_factory=datetime.utcnow) + metadata: dict = field(default_factory=dict) + + +class TaskProgressMessage(Message): + """Message for task progress updates.""" + def __init__(self, progress: TaskProgress): + self.progress = progress + super().__init__() + + +class TaskCompletedMessage(Message): + """Message for task completion.""" + def __init__(self, result: TaskResult): + self.result = result + super().__init__() + + +class TaskRunner(Widget): + """ + Background task runner with: + - Async task execution (I/O-bound) + - ProcessPoolExecutor for CPU-bound work (multiprocessing) + - Progress tracking via messages + - Task history with persistence + - Cancellation support + - Max concurrent tasks limit + """ + + def __init__( + self, + max_concurrent: int = 3, + max_history: int = 100, + process_pool_size: int = None, + ): + super().__init__() + self.max_concurrent = max_concurrent + self.max_history = max_history + self.process_pool_size = process_pool_size or min(4, (os.cpu_count() or 4)) + + self._tasks: dict[str, asyncio.Task] = {} + self._progress: dict[str, TaskProgress] = {} + self._history: list[TaskResult] = [] + self._process_pool: ProcessPoolExecutor | None = None + self._semaphore = asyncio.Semaphore(max_concurrent) + + @property + def process_pool(self) -> ProcessPoolExecutor: + """Lazy-initialize process pool.""" + if self._process_pool is None: + self._process_pool = ProcessPoolExecutor(max_workers=self.process_pool_size) + return self._process_pool + + async def run_task( + self, + coro_factory: Callable[[], Any], + task_id: str | None = None, + name: str = "Task", + progress_callback: Callable[[TaskProgress], None] | None = None, + ) -> TaskResult: + """ + Run a coroutine as a background task. + """ + task_id = task_id or str(uuid.uuid4())[:8] + + async with self._semaphore: + progress = TaskProgress( + task_id=task_id, + status=TaskStatus.RUNNING, + message=f"Starting {name}...", + ) + self._progress[task_id] = progress + self.post_message(TaskProgressMessage(progress)) + + if progress_callback: + progress_callback(progress) + + task = asyncio.create_task(self._run_task_impl( + task_id, name, coro_factory, progress, progress_callback + )) + self._tasks[task_id] = task + + try: + result = await task + return result + finally: + self._tasks.pop(task_id, None) + + async def _run_task_impl( + self, + task_id: str, + name: str, + coro_factory: Callable, + progress: TaskProgress, + progress_callback: Callable | None, + ) -> TaskResult: + """Internal task implementation with error handling.""" + try: + coro = coro_factory() + result = await coro + + progress.status = TaskStatus.COMPLETED + progress.current = progress.total or progress.current + progress.message = f"{name} completed" + progress.completed_at = datetime.utcnow() + + task_result = TaskResult( + task_id=task_id, + status=TaskStatus.COMPLETED, + result=result, + completed_at=progress.completed_at, + ) + + except asyncio.CancelledError: + progress.status = TaskStatus.CANCELLED + progress.message = f"{name} cancelled" + progress.completed_at = datetime.utcnow() + + task_result = TaskResult( + task_id=task_id, + status=TaskStatus.CANCELLED, + error="Cancelled", + completed_at=progress.completed_at, + ) + raise + + except Exception as e: + logger.error("task_failed", task_id=task_id, name=name, error=str(e)) + progress.status = TaskStatus.FAILED + progress.message = f"{name} failed: {e}" + progress.error = str(e) + progress.completed_at = datetime.utcnow() + + task_result = TaskResult( + task_id=task_id, + status=TaskStatus.FAILED, + error=str(e), + completed_at=progress.completed_at, + ) + + self.post_message(TaskProgressMessage(progress)) + if progress_callback: + progress_callback(progress) + + self._add_to_history(task_result) + self.post_message(TaskCompletedMessage(task_result)) + + return task_result + + def _add_to_history(self, result: TaskResult) -> None: + self._history.append(result) + if len(self._history) > self.max_history: + self._history = self._history[-self.max_history:] + + def get_progress(self, task_id: str) -> TaskProgress | None: + return self._progress.get(task_id) + + def get_all_progress(self) -> list[TaskProgress]: + return list(self._progress.values()) + + def get_history(self) -> list[TaskResult]: + return list(self._history) + + async def cancel_task(self, task_id: str) -> bool: + task = self._tasks.get(task_id) + if task and not task.done(): + task.cancel() + return True + return False + + async def cancel_all(self) -> int: + count = 0 + for task in self._tasks.values(): + if not task.done(): + task.cancel() + count += 1 + return count + + async def run_in_process_pool(self, func: Callable, *args, **kwargs) -> Any: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(self.process_pool, func, *args, **kwargs) + + async def close(self) -> None: + await self.cancel_all() + if self._tasks: + await asyncio.gather(*self._tasks.values(), return_exceptions=True) + if self._process_pool: + self._process_pool.shutdown(wait=True) + self._process_pool = None + + +def merge_csv_chunks(chunk_paths: list[str], output_path: str) -> dict: + """Merge multiple CSV chunks into single file.""" + import pandas as pd + + chunks = [] + total_rows = 0 + + for path in chunk_paths: + df = pd.read_csv(path) + chunks.append(df) + total_rows += len(df) + + if chunks: + combined = pd.concat(chunks, ignore_index=True) + combined.to_csv(output_path, index=False) + + return { + "output_path": output_path, + "total_rows": total_rows, + "chunks_merged": len(chunks), + } + + +def process_csv_chunk(args: tuple) -> dict: + """Process a single CSV chunk for upload.""" + import base64 + + import pandas as pd + + chunk_data, chunk_index, total_chunks = args + df = pd.read_csv(chunk_data) + csv_bytes = df.to_csv(index=False).encode() + b64 = base64.b64encode(csv_bytes).decode() + + return { + "part_number": chunk_index + 1, + "data_file_base64": b64, + "rows": len(df), + } + + +def split_csv_for_parallel(input_path: str, num_chunks: int, output_dir: str) -> list[str]: + """ + Split large CSV into chunks for parallel processing. + + Returns list of chunk file paths. + """ + from pathlib import Path + + import pandas as pd + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + chunk_paths = [] + chunk_size = 100000 # 100k rows per chunk + + for i, chunk in enumerate(pd.read_csv(input_path, chunksize=chunk_size)): + if i >= num_chunks: + break + chunk_path = output_dir / f"chunk_{i:04d}.csv" + chunk.to_csv(chunk_path, index=False) + chunk_paths.append(str(chunk_path)) + + return chunk_paths + diff --git a/tcrm_toolkit/interactive/widgets/__init__.py b/tcrm_toolkit/interactive/widgets/__init__.py new file mode 100644 index 0000000..24b1693 --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/__init__.py @@ -0,0 +1,10 @@ +"""Interactive widgets.""" + +from tcrm_toolkit.interactive.widgets.context_menu import ContextMenu as ContextMenu +from tcrm_toolkit.interactive.widgets.data_table import ColumnConfig as ColumnConfig +from tcrm_toolkit.interactive.widgets.data_table import DataBrowser as DataBrowser +from tcrm_toolkit.interactive.widgets.detail_panel import DetailPanel as DetailPanel +from tcrm_toolkit.interactive.widgets.status_bar import StatusBar as StatusBar + +__all__ = ["ContextMenu", "ColumnConfig", "DataBrowser", "DetailPanel", "StatusBar"] + diff --git a/tcrm_toolkit/interactive/widgets/command_palette.py b/tcrm_toolkit/interactive/widgets/command_palette.py new file mode 100644 index 0000000..a64cea6 --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/command_palette.py @@ -0,0 +1,58 @@ +"""Command Palette modal for fuzzy searching and executing actions.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Input, ListItem, ListView, Static + + +class CommandPaletteScreen(ModalScreen[str | None]): + """Modal screen for command palette search and execution.""" + + def __init__(self, commands: list[tuple[str, str]]): + """ + commands: list of (display_name, action_id) + """ + super().__init__() + self.commands = commands + self.filtered_commands = list(commands) + + def compose(self) -> ComposeResult: + with Container(id="palette-dialog"): + yield Static("Command Palette", id="palette-title") + yield Input(placeholder="Type to search commands...", id="search-input") + yield ListView(id="nav-list") + + def on_mount(self) -> None: + self.query_one("#search-input", Input).focus() + self._populate_list(self.commands) + + def _populate_list(self, cmds: list[tuple[str, str]]) -> None: + list_view = self.query_one("#nav-list", ListView) + list_view.clear() + for display_name, action_id in cmds: + item = ListItem(Static(display_name)) + item.action_id = action_id + list_view.append(item) + + def on_input_changed(self, event: Input.Changed) -> None: + query = event.value.lower() + if not query: + self.filtered_commands = list(self.commands) + else: + self.filtered_commands = [ + (name, act) for name, act in self.commands if query in name.lower() or query in act.lower() + ] + self._populate_list(self.filtered_commands) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + if hasattr(item, "action_id"): + self.dismiss(item.action_id) + + def on_key(self, event) -> None: + if event.key == "escape": + self.dismiss(None) + diff --git a/tcrm_toolkit/interactive/widgets/context_menu.py b/tcrm_toolkit/interactive/widgets/context_menu.py new file mode 100644 index 0000000..621c654 --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/context_menu.py @@ -0,0 +1,49 @@ +"""Context menu for row actions.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Button + + +class ContextMenu(ModalScreen[str]): + """Context menu for row actions.""" + + def __init__(self, actions: list[tuple[str, str]], x: int, y: int): + super().__init__() + self.actions = actions # List of (label, action_id) + self._x = x + self._y = y + + def compose(self) -> ComposeResult: + yield Container( + Container( + *[Button(label, id=f"action-{i}", variant="default") for i, (label, _) in enumerate(self.actions)], + id="context-menu-items" + ), + id="context-menu" + ) + + def on_mount(self) -> None: + # Position menu at cursor + try: + menu = self.query_one("#context-menu", Container) + menu.styles.offset = (self._x, self._y) + except Exception: + pass + + @on(Button.Pressed) + def on_action_selected(self, event: Button.Pressed) -> None: + if event.button.id and event.button.id.startswith("action-"): + try: + idx = int(event.button.id.split("-")[1]) + if idx < len(self.actions): + _, action_id = self.actions[idx] + self.dismiss(action_id) + except Exception: + self.dismiss(None) + + def on_click(self, event) -> None: + # Click outside closes menu + self.dismiss(None) diff --git a/tcrm_toolkit/interactive/widgets/data_table.py b/tcrm_toolkit/interactive/widgets/data_table.py new file mode 100644 index 0000000..70e3d6c --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/data_table.py @@ -0,0 +1,321 @@ +"""Enhanced DataTable with search, filter, sort, and pagination.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from textual import on +from textual.app import ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.widget import Widget +from textual.widgets import DataTable, Input, Label, Static + +from tcrm_toolkit.interactive.window_manager import WindowManager + +T = TypeVar("T") + + +@dataclass +class ColumnConfig: + """Configuration for a table column.""" + key: str + title: str + width: int | None = None + sortable: bool = True + filterable: bool = True + formatter: Callable[[Any], str] | None = None + + +class DataBrowser(Widget, Generic[T]): + """ + Generic data browser with: + - Search/filter input + - Sortable columns (click header) + - Server-side / client-side pagination + - Row selection -> detail panel + - Keyboard navigation (j/k, enter, /, escape) + - Context menu (ctrl+m) + """ + + BINDINGS = [ + ("/", "focus_search", "Search"), + ("escape", "clear_search", "Clear Search"), + ("enter", "select_row", "Select"), + ("j", "cursor_down", "Down"), + ("k", "cursor_up", "Up"), + ("ctrl+m", "context_menu", "Context Menu"), + ] + + def __init__( + self, + columns: list[ColumnConfig], + load_data: Callable[[int, int, str | None, str | None], Any], + get_row_id: Callable[[T], str], + get_row_data: Callable[[T], dict[str, Any]], + title: str = "Data Browser", + page_size: int = 50, + id: str | None = None, + ): + super().__init__(id=id) + self.columns = columns + self.load_data = load_data + self.get_row_id = get_row_id + self.get_row_data = get_row_data + self.title = title + self.page_size = page_size + + # State + self._all_rows: list[T] = [] + self._filtered_rows: list[T] = [] + self._current_page = 0 + self._total_pages = 0 + self._total_count = 0 + self._search_query = "" + self._sort_column: str | None = None + self._sort_reverse = False + self._loading = False + + def compose(self) -> ComposeResult: + yield Vertical( + Horizontal( + Label(self.title, id="browser-title"), + Input(placeholder="Search... (Press /)", id="search-input"), + Static("", id="pagination-info"), + id="browser-header" + ), + Container( + DataTable(id="data-table", cursor_type="row", zebra_stripes=True), + id="table-container" + ), + Static("", id="browser-status"), + id="browser-container" + ) + + async def on_mount(self) -> None: + """Initialize table columns and load first page.""" + table = self.query_one("#data-table", DataTable) + + from tcrm_toolkit.interactive.window_manager import WindowManager + win_mgr = WindowManager() + browser_id = self.id or self.title.lower().replace(" ", "_") + saved_widths = win_mgr.get(f"col_widths_{browser_id}", {}) + + # Add columns + for col in self.columns: + if col.key in saved_widths: + col.width = saved_widths[col.key] + table.add_column(col.title, key=col.key, width=col.width) + + # Load initial data + await self._load_page(0) + + def on_unmount(self) -> None: + """Save column widths on unmount.""" + try: + table = self.query_one("#data-table", DataTable) + win_mgr = WindowManager() + browser_id = self.id or self.title.lower().replace(" ", "_") + widths = {} + for col in self.columns: + try: + c = table.get_column(col.key) + if hasattr(c, 'width') and c.width: + widths[col.key] = c.width + except Exception: + if col.width: + widths[col.key] = col.width + win_mgr.set(f"col_widths_{browser_id}", widths) + except Exception: + pass + + @on(DataTable.HeaderSelected, "#data-table") + async def on_header_selected(self, event: DataTable.HeaderSelected) -> None: + """Handle column header click for sorting.""" + column_key = event.column_key.value + + # Find column config + col_config = next((c for c in self.columns if c.key == column_key), None) + if not col_config or not col_config.sortable: + return + + # Toggle sort direction + if self._sort_column == column_key: + self._sort_reverse = not self._sort_reverse + else: + self._sort_column = column_key + self._sort_reverse = False + + # Reload with new sort + await self._load_page(0) + + @on(Input.Changed, "#search-input") + async def on_search_changed(self, event: Input.Changed) -> None: + """Handle search input changes (debounced).""" + self._search_query = event.value + # Debounce: wait 300ms before filtering + await asyncio.sleep(0.3) + if self._search_query == event.value: # Still current + await self._apply_filter() + + async def _apply_filter(self) -> None: + """Apply client-side filter to loaded data.""" + if not self._search_query: + self._filtered_rows = self._all_rows + else: + query = self._search_query.lower() + self._filtered_rows = [ + row for row in self._all_rows + if any( + query in str(self.get_row_data(row).get(col.key, "")).lower() + for col in self.columns if col.filterable + ) + ] + + self._current_page = 0 + await self._render_page() + + async def _load_page(self, page: int) -> None: + """Load page from server.""" + if self._loading: + return + + self._loading = True + try: + status = self.query_one("#browser-status", Static) + status.update("Loading...") + except Exception: + pass + + try: + offset = page * self.page_size + sort_col = self._sort_column + sort_dir = "desc" if self._sort_reverse else "asc" + + rows, total_count = await self.load_data( + offset=offset, + limit=self.page_size, + search=self._search_query or None, + sort=f"{sort_col}:{sort_dir}" if sort_col else None, + ) + + self._all_rows = rows + self._total_count = total_count + self._total_pages = max(1, (total_count + self.page_size - 1) // self.page_size) + self._current_page = page + + await self._apply_filter() + + except Exception as e: + try: + self.query_one("#browser-status", Static).update(f"Error: {e}") + except Exception: + pass + finally: + self._loading = False + + async def _render_page(self) -> None: + """Render current page to table.""" + try: + table = self.query_one("#data-table", DataTable) + table.clear() + + start = self._current_page * self.page_size + end = start + self.page_size + page_rows = self._filtered_rows[start:end] + + for i, row in enumerate(page_rows, start + 1): + row_data = self.get_row_data(row) + row_key = self.get_row_id(row) + + cell_values = [] + for col in self.columns: + val = row_data.get(col.key, "") + if col.formatter: + val = col.formatter(val) + cell_values.append(str(val)) + + table.add_row(*cell_values, key=row_key) + + showing = len(page_rows) + self.query_one("#pagination-info", Static).update( + f"Page {self._current_page + 1}/{self._total_pages} | " + f"Showing {start + 1}-{start + showing if showing > 0 else start} of {len(self._filtered_rows)} " + f"(filtered from {self._total_count})" + ) + + self.query_one("#browser-status", Static).update("") + except Exception: + pass + + def refresh( + self, + *regions: Any, + repaint: bool = True, + layout: bool = False, + recompose: bool = False, + **kwargs: Any, + ) -> Any: + """Refresh widget and current page data.""" + result = super().refresh(*regions, repaint=repaint, layout=layout, recompose=recompose, **kwargs) + if self._is_mounted: + try: + self.run_worker(self._load_page(self._current_page), exclusive=False) + except Exception: + pass + return result + + async def reload_data(self) -> None: + """Reload current page data.""" + await self._load_page(self._current_page) + + # Actions + async def action_focus_search(self) -> None: + try: + self.query_one("#search-input", Input).focus() + except Exception: + pass + + async def action_clear_search(self) -> None: + try: + search = self.query_one("#search-input", Input) + if search.value: + search.value = "" + await self._apply_filter() + except Exception: + pass + + async def action_select_row(self) -> None: + """Emit selected row event.""" + try: + table = self.query_one("#data-table", DataTable) + if table.cursor_row >= 0 and table.row_count > 0: + row_key = table.get_row_at(table.cursor_row).key + row = next((r for r in self._filtered_rows if self.get_row_id(r) == row_key), None) + if row: + self.post_message(self.RowSelected(row)) + except Exception: + pass + + async def action_cursor_down(self) -> None: + try: + table = self.query_one("#data-table", DataTable) + table.action_cursor_down() + except Exception: + pass + + async def action_cursor_up(self) -> None: + try: + table = self.query_one("#data-table", DataTable) + table.action_cursor_up() + except Exception: + pass + + async def action_context_menu(self) -> None: + pass + + @dataclass + class RowSelected: + row: T diff --git a/tcrm_toolkit/interactive/widgets/detail_panel.py b/tcrm_toolkit/interactive/widgets/detail_panel.py new file mode 100644 index 0000000..8cab30d --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/detail_panel.py @@ -0,0 +1,115 @@ +"""Detail panel widget for showing entity details.""" + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widget import Widget +from textual.widgets import Label, Static + + +class DetailPanel(Widget): + """Right-side detail panel for showing selected item details.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._content = Static("Select an item to view details", id="detail-content") + + def compose(self) -> ComposeResult: + yield Vertical( + Label("Details", id="detail-title"), + self._content, + id="detail-container" + ) + + def show_dataset(self, dataset) -> None: + from tcrm_toolkit.core.models import Dataset + if not isinstance(dataset, Dataset): + return + + row_count_str = f"{dataset.row_count:,}" if dataset.row_count is not None else 'N/A' + created_str = dataset.created_date.strftime('%Y-%m-%d %H:%M') if dataset.created_date else 'N/A' + modified_str = dataset.last_modified_date.strftime('%Y-%m-%d %H:%M') if dataset.last_modified_date else 'N/A' + + content = f"""[bold]Dataset Details[/bold] + +[cyan]ID:[/cyan] {dataset.id} +[cyan]Name:[/cyan] {dataset.name} +[cyan]Label:[/cyan] {dataset.label} +[cyan]Description:[/cyan] {dataset.description or 'N/A'} +[cyan]Status:[/cyan] {dataset.status} +[cyan]Type:[/cyan] {dataset.type} +[cyan]Row Count:[/cyan] {row_count_str} +[cyan]Created:[/cyan] {created_str} +[cyan]Last Modified:[/cyan] {modified_str} +[cyan]Current Version:[/cyan] {dataset.current_version_id or 'N/A'} +""" + self._content.update(content) + + def show_dashboard(self, dashboard) -> None: + from tcrm_toolkit.core.models import Dashboard + if not isinstance(dashboard, Dashboard): + return + + created_str = dashboard.created_date.strftime('%Y-%m-%d %H:%M') if dashboard.created_date else 'N/A' + modified_str = dashboard.last_modified_date.strftime('%Y-%m-%d %H:%M') if dashboard.last_modified_date else 'N/A' + + content = f"""[bold]Dashboard Details[/bold] + +[cyan]ID:[/cyan] {dashboard.id} +[cyan]Name:[/cyan] {dashboard.name} +[cyan]Label:[/cyan] {dashboard.label} +[cyan]Description:[/cyan] {dashboard.description or 'N/A'} +[cyan]Folder:[/cyan] {dashboard.folder_name or 'N/A'} +[cyan]Created:[/cyan] {created_str} +[cyan]Last Modified:[/cyan] {modified_str} +""" + self._content.update(content) + + def show_dataflow(self, dataflow) -> None: + from tcrm_toolkit.core.models import Dataflow + if not isinstance(dataflow, Dataflow): + return + + created_str = dataflow.created_date.strftime('%Y-%m-%d %H:%M') if dataflow.created_date else 'N/A' + modified_str = dataflow.last_modified_date.strftime('%Y-%m-%d %H:%M') if dataflow.last_modified_date else 'N/A' + + content = f"""[bold]Dataflow Details[/bold] + +[cyan]ID:[/cyan] {dataflow.id} +[cyan]Name:[/cyan] {dataflow.name} +[cyan]Label:[/cyan] {dataflow.label} +[cyan]Description:[/cyan] {dataflow.description or 'N/A'} +[cyan]Status:[/cyan] {dataflow.status} +[cyan]Created:[/cyan] {created_str} +[cyan]Last Modified:[/cyan] {modified_str} +""" + self._content.update(content) + + def show_dataflow_job(self, job) -> None: + """Show dataflow job details.""" + from tcrm_toolkit.core.models import DataflowJob + if not isinstance(job, DataflowJob): + return + + content = f"""[bold]Dataflow Job Details[/bold] + +[cyan]Job ID:[/cyan] {job.id} +[cyan]Dataflow:[/cyan] {job.dataflow_name} +[cyan]Command:[/cyan] {job.command} +[cyan]Status:[/cyan] {job.status} +[cyan]Start Time:[/cyan] {job.start_time.strftime('%Y-%m-%d %H:%M') if job.start_time else 'N/A'} +[cyan]End Time:[/cyan] {job.end_time.strftime('%Y-%m-%d %H:%M') if job.end_time else 'N/A'} +[cyan]Duration:[/cyan] {self._format_duration(job.start_time, job.end_time) if job.start_time else 'N/A'} +""" + self._content.update(content) + + def _format_duration(self, start, end) -> str: + if not start or not end: + return "N/A" + delta = end - start + hours = delta.seconds // 3600 + minutes = (delta.seconds % 3600) // 60 + return f"{hours}h {minutes}m" + + def clear(self) -> None: + self._content.update("Select an item to view details") + diff --git a/tcrm_toolkit/interactive/widgets/progress_panel.py b/tcrm_toolkit/interactive/widgets/progress_panel.py new file mode 100644 index 0000000..e171811 --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/progress_panel.py @@ -0,0 +1,47 @@ +"""Progress panel for showing running task progress.""" + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import DataTable, Label, Static + +from tcrm_toolkit.interactive.tasks import TaskRunner + + +class ProgressPanel(Static): + """Panel showing active task progress bars.""" + + def __init__(self, task_runner: TaskRunner): + super().__init__(id="progress-panel") + self.task_runner = task_runner + + def compose(self) -> ComposeResult: + yield Vertical( + Label("Active Tasks", id="progress-title"), + DataTable(id="progress-table", cursor_type="row"), + id="progress-container" + ) + + async def on_mount(self) -> None: + table = self.query_one("#progress-table", DataTable) + table.add_columns("Task", "Status", "Progress", "Details") + self.set_interval(1.0, self.update_progress) + + def update_progress(self) -> None: + """Update progress table from task runner.""" + table = self.query_one("#progress-table", DataTable) + table.clear() + + for progress in self.task_runner.get_all_progress(): + if progress.is_finished: + continue + + pct = progress.percent + bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5)) + + table.add_row( + progress.task_id, + progress.status.value, + f"{bar} {pct:.1f}%", + progress.message, + ) + diff --git a/tcrm_toolkit/interactive/widgets/status_bar.py b/tcrm_toolkit/interactive/widgets/status_bar.py new file mode 100644 index 0000000..07d12b4 --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/status_bar.py @@ -0,0 +1,59 @@ +"""Status bar widget for bottom of TUI.""" + +from textual.widgets import Static + +from tcrm_toolkit.interactive.safety import RiskLevel, SafetyResult +from tcrm_toolkit.interactive.session import OrgSession + + +class StatusBar(Static): + """Bottom status bar showing org, safety, API usage, background tasks.""" + + def __init__(self, **kwargs): + super().__init__("", **kwargs) + self._org: OrgSession | None = None + self._safety: SafetyResult | None = None + self._api_usage = "0/15,000" + self._bg_tasks = 0 + + def update_org(self, org: OrgSession | None) -> None: + self._org = org + self.refresh() + + def update_safety(self, safety: SafetyResult) -> None: + self._safety = safety + self.refresh() + + def update_api_usage(self, used: int, limit: int) -> None: + self._api_usage = f"{used:,}/{limit:,}" + self.refresh() + + def update_bg_tasks(self, count: int) -> None: + self._bg_tasks = count + self.refresh() + + def render(self) -> str: + parts = [] + + if self._org: + parts.append(f"Org: {self._org.alias} ({self._org.username})") + else: + parts.append("Org: Not connected") + + if self._safety: + if self._safety.risk_level == RiskLevel.CRITICAL: + parts.append("🔴 UNSAFE") + elif self._safety.risk_level == RiskLevel.WARNING: + parts.append("🟡 WARNING") + else: + parts.append("🟢 SAFE") + else: + parts.append("🟢 SAFE") + + parts.append(f"API: {self._api_usage}") + + if self._bg_tasks > 0: + parts.append(f"BG: {self._bg_tasks} running") + + return " • ".join(parts) + diff --git a/tcrm_toolkit/interactive/widgets/task_history.py b/tcrm_toolkit/interactive/widgets/task_history.py new file mode 100644 index 0000000..00ea4bd --- /dev/null +++ b/tcrm_toolkit/interactive/widgets/task_history.py @@ -0,0 +1,85 @@ +"""Task history panel for viewing past operations.""" + +from textual import on +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import DataTable, Label, Static, TabbedContent, TabPane + +from tcrm_toolkit.interactive.tasks import TaskRunner, TaskStatus + + +class TaskHistory(Static): + """Panel showing task history with filtering.""" + + def __init__(self, task_runner: TaskRunner): + super().__init__(id="task-history") + self.task_runner = task_runner + self._filter_status: TaskStatus | None = None + + def compose(self) -> ComposeResult: + yield Vertical( + Label("Task History", id="history-title"), + TabbedContent( + TabPane("All", id="tab-all"), + TabPane("Running", id="tab-running"), + TabPane("Completed", id="tab-completed"), + TabPane("Failed", id="tab-failed"), + id="history-tabs" + ), + DataTable(id="history-table", cursor_type="row", zebra_stripes=True), + id="history-container" + ) + + async def on_mount(self) -> None: + table = self.query_one("#history-table", DataTable) + table.add_columns("Time", "Task", "Status", "Duration", "Details") + table.zebra_stripes = True + await self.refresh_history() + + @on(TabbedContent.TabActivated, "#history-tabs") + async def on_tab_changed(self, event: TabbedContent.TabActivated) -> None: + tab_map = { + "tab-all": None, + "tab-running": TaskStatus.RUNNING, + "tab-completed": TaskStatus.COMPLETED, + "tab-failed": TaskStatus.FAILED, + } + self._filter_status = tab_map.get(event.tab.id) + await self.refresh_history() + + async def refresh_history(self) -> None: + """Refresh history table.""" + table = self.query_one("#history-table", DataTable) + table.clear() + + history = self.task_runner.get_history() + + if self._filter_status: + history = [r for r in history if r.status == self._filter_status] + + for result in reversed(history[-100:]): + duration = "" + if result.completed_at and result.started_at: + delta = result.completed_at - result.started_at + duration = f"{delta.total_seconds():.1f}s" + + status_style = { + TaskStatus.COMPLETED: "[green]", + TaskStatus.FAILED: "[red]", + TaskStatus.CANCELLED: "[yellow]", + TaskStatus.RUNNING: "[blue]", + }.get(result.status, "") + + details = result.error or str(result.result)[:50] if result.result else "" + + table.add_row( + result.started_at.strftime("%H:%M:%S"), + result.task_id, + f"{status_style}{result.status.value}[/]", + duration, + details, + ) + + +TaskHistoryPanel = TaskHistory + diff --git a/tcrm_toolkit/interactive/window_manager.py b/tcrm_toolkit/interactive/window_manager.py new file mode 100644 index 0000000..0070b5d --- /dev/null +++ b/tcrm_toolkit/interactive/window_manager.py @@ -0,0 +1,41 @@ +"""Window state and UI preference persistence.""" + +import json +from pathlib import Path +from typing import Any + + +class WindowManager: + """Manages window state, column widths, and UI preferences.""" + + def __init__(self, config_dir: Path | None = None): + self.config_dir = config_dir or (Path.home() / ".tcrm") + self.state_file = self.config_dir / "window_state.json" + self._state: dict[str, Any] = {} + self.load() + + def load(self) -> dict[str, Any]: + """Load window state from disk.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + if self.state_file.exists(): + try: + self._state = json.loads(self.state_file.read_text(encoding="utf-8")) + except Exception: + self._state = {} + else: + self._state = {} + return self._state + + def save(self) -> None: + """Save window state to disk.""" + self.config_dir.mkdir(parents=True, exist_ok=True) + self.state_file.write_text(json.dumps(self._state, indent=2), encoding="utf-8") + + def get(self, key: str, default: Any = None) -> Any: + """Get state value.""" + return self._state.get(key, default) + + def set(self, key: str, value: Any) -> None: + """Set state value and save.""" + self._state[key] = value + self.save() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3ce4c88 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for TCRM Toolkit.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ffdebb7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,85 @@ +"""Pytest configuration and fixtures.""" + +import pytest + + +@pytest.fixture(scope="session") +def event_loop(): + """Create event loop for async tests.""" + import asyncio + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture +def sample_dataset(): + """Sample dataset for testing.""" + from tcrm_toolkit.core.models import Dataset + return Dataset( + id="0Fb000000000001", + name="TestDataset", + label="Test Dataset", + currentVersionId="0Fc000000000001", + createdDate="2024-01-01T00:00:00.000Z", + createdById="005000000000001", + lastModifiedDate="2024-01-01T00:00:00.000Z", + lastModifiedById="005000000000001", + rowCount=1000, + status="Active", + type="Edgemart", + ) + + +@pytest.fixture +def sample_dashboard(): + """Sample dashboard for testing.""" + from tcrm_toolkit.core.models import Dashboard + return Dashboard( + id="0FK000000000001", + name="TestDashboard", + label="Test Dashboard", + folderId="00l000000000001", + folderName="Test Folder", + createdDate="2024-01-01T00:00:00.000Z", + createdById="005000000000001", + lastModifiedDate="2024-01-01T00:00:00.000Z", + lastModifiedById="005000000000001", + ) + + +@pytest.fixture +def sample_dataflow(): + """Sample dataflow for testing.""" + from tcrm_toolkit.core.models import Dataflow + return Dataflow( + id="03C000000000001", + name="TestDataflow", + label="Test Dataflow", + status="Active", + createdDate="2024-01-01T00:00:00.000Z", + createdById="005000000000001", + lastModifiedDate="2024-01-01T00:00:00.000Z", + lastModifiedById="005000000000001", + ) + + +@pytest.fixture(autouse=True) +def _set_test_env(monkeypatch): + """Set default test environment variables for settings.""" + import base64 + test_enc_key = base64.urlsafe_b64encode(b"12345678901234567890123456789012").decode() + monkeypatch.setenv("ENCRYPTION_KEY", test_enc_key) + monkeypatch.setenv("JWT_SECRET_KEY", "super_secret_jwt_key_that_is_long_enough_12345") + + +@pytest.fixture +def settings(): + """Return Settings instance configured for tests.""" + import base64 + + from tcrm_toolkit.core.config import Settings + return Settings( + encryption_key=base64.urlsafe_b64encode(b"12345678901234567890123456789012").decode(), + jwt_secret_key="super_secret_jwt_key_that_is_long_enough_12345", + ) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..c66cd71 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" diff --git a/tests/integration/test_api_endpoints.py b/tests/integration/test_api_endpoints.py new file mode 100644 index 0000000..624de94 --- /dev/null +++ b/tests/integration/test_api_endpoints.py @@ -0,0 +1,309 @@ +"""Integration tests for API endpoints with mocked responses.""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from tcrm_toolkit.core.client import SalesforceClient +from tcrm_toolkit.core.config import Settings +from tcrm_toolkit.core.services.dashboard_service import DashboardService +from tcrm_toolkit.core.services.dataflow_service import DataflowService +from tcrm_toolkit.core.services.dataset_service import DatasetService + + +class MockResponse: + """Mock HTTP response.""" + + def __init__(self, json_data, status_code=200, headers=None): + self._json_data = json_data + self.status_code = status_code + self.headers = headers or {} + + def json(self): + return self._json_data + + def raise_for_status(self): + if self.status_code >= 400: + raise httpx.HTTPStatusError( + f"HTTP {self.status_code}", + request=MagicMock(), + response=self, + ) + + +@pytest.fixture +def settings(): + """Create test settings.""" + import base64 + encryption_key = base64.urlsafe_b64encode(b"x" * 32).decode() + jwt_secret_key = base64.urlsafe_b64encode(b"y" * 32).decode() + return Settings( + encryption_key=encryption_key, + jwt_secret_key=jwt_secret_key, + sf_api_version="v60.0", + sf_default_domain="test.salesforce.com", + ) + + +@pytest.fixture +def mock_client(settings): + """Create a client with mocked HTTP client.""" + client = SalesforceClient( + access_token="test_token", + instance_url="https://test.salesforce.com", + settings=settings, + ) + client._client = AsyncMock() + return client + + +class TestDatasetEndpoints: + """Integration tests for dataset endpoints.""" + + @pytest.mark.asyncio + async def test_list_datasets(self, mock_client): + """Test listing datasets.""" + mock_response = MockResponse({ + "datasets": [ + { + "id": "0Fb000000000001", + "name": "TestDataset", + "label": "Test Dataset", + "currentVersionId": "0Fc000000000001", + "currentVersionUrl": "/services/data/v60.0/wave/datasets/0Fb000000000001/versions/0Fc000000000001", + "versionsUrl": "/services/data/v60.0/wave/datasets/0Fb000000000001/versions", + "historiesUrl": "/services/data/v60.0/wave/datasets/0Fb000000000001/histories", + "createdDate": "2024-01-01T00:00:00.000Z", + "createdById": "005000000000001", + "lastModifiedDate": "2024-01-01T00:00:00.000Z", + "lastModifiedById": "005000000000001", + "rowCount": 1000, + "status": "Active", + "type": "Edgemart", + } + ], + "nextPageUrl": None, + }) + mock_client._client.request.return_value = mock_response + + datasets = await mock_client.list_datasets() + + assert len(datasets["datasets"]) == 1 + assert datasets["datasets"][0]["name"] == "TestDataset" + + @pytest.mark.asyncio + async def test_get_dataset(self, mock_client): + """Test getting a single dataset.""" + mock_response = MockResponse({ + "id": "0Fb000000000001", + "name": "TestDataset", + "label": "Test Dataset", + "currentVersionId": "0Fc000000000001", + "createdDate": "2024-01-01T00:00:00.000Z", + "createdById": "005000000000001", + "lastModifiedDate": "2024-01-01T00:00:00.000Z", + "lastModifiedById": "005000000000001", + "rowCount": 1000, + "status": "Active", + "type": "Edgemart", + }) + mock_client._client.request.return_value = mock_response + + dataset = await mock_client.get_dataset("0Fb000000000001") + + assert dataset["id"] == "0Fb000000000001" + assert dataset["name"] == "TestDataset" + + @pytest.mark.asyncio + async def test_saql_query(self, mock_client): + """Test SAQL query execution.""" + mock_response = MockResponse({ + "results": { + "records": [ + {"count": 1000}, + ] + } + }) + mock_client._client.request.return_value = mock_response + + result = await mock_client.saql_query('q = load "test"; q = group q by all; q = foreach q generate count() as "count";') + + assert result["results"]["records"][0]["count"] == 1000 + + +class TestDashboardEndpoints: + """Integration tests for dashboard endpoints.""" + + @pytest.mark.asyncio + async def test_list_dashboards(self, mock_client): + """Test listing dashboards.""" + mock_response = MockResponse({ + "dashboards": [ + { + "id": "0FK000000000001", + "name": "TestDashboard", + "label": "Test Dashboard", + "folderId": "00l000000000001", + "folderName": "Test Folder", + "createdDate": "2024-01-01T00:00:00.000Z", + "createdById": "005000000000001", + "lastModifiedDate": "2024-01-01T00:00:00.000Z", + "lastModifiedById": "005000000000001", + "historiesUrl": "/services/data/v60.0/wave/dashboards/0FK000000000001/histories", + "datasetsUrl": "/services/data/v60.0/wave/dashboards/0FK000000000001/datasets", + } + ], + "nextPageUrl": None, + }) + mock_client._client.request.return_value = mock_response + + dashboards = await mock_client.list_dashboards() + + assert len(dashboards["dashboards"]) == 1 + assert dashboards["dashboards"][0]["name"] == "TestDashboard" + + +class TestDataflowEndpoints: + """Integration tests for dataflow endpoints.""" + + @pytest.mark.asyncio + async def test_list_dataflows(self, mock_client): + """Test listing dataflows.""" + mock_response = MockResponse({ + "dataflows": [ + { + "id": "03C000000000001", + "name": "TestDataflow", + "label": "Test Dataflow", + "status": "Active", + "createdDate": "2024-01-01T00:00:00.000Z", + "createdById": "005000000000001", + "lastModifiedDate": "2024-01-01T00:00:00.000Z", + "lastModifiedById": "005000000000001", + "historiesUrl": "/services/data/v60.0/wave/dataflows/03C000000000001/histories", + } + ], + }) + mock_client._client.request.return_value = mock_response + + dataflows = await mock_client.list_dataflows() + + assert len(dataflows["dataflows"]) == 1 + assert dataflows["dataflows"][0]["name"] == "TestDataflow" + + @pytest.mark.asyncio + async def test_start_dataflow(self, mock_client): + """Test starting a dataflow.""" + mock_response = MockResponse({ + "id": "03D000000000001", + "dataflowId": "03C000000000001", + "command": "start", + "status": "Queued", + }) + mock_client._client.request.return_value = mock_response + + job = await mock_client.start_dataflow("03C000000000001") + + assert job["id"] == "03D000000000001" + assert job["command"] == "start" + + +class TestDatasetServiceIntegration: + """Integration tests for DatasetService.""" + + @pytest.mark.asyncio + async def test_extract_fields_from_xmd(self, mock_client, settings): + """Test field extraction from XMD.""" + service = DatasetService(mock_client, settings=settings) + + from tcrm_toolkit.core.models import DatasetXMD + xmd = DatasetXMD( + measures=[ + {"field": "Amount", "label": "Amount"}, + {"field": "Quantity_epoch", "label": "Quantity Epoch"}, # Should be excluded + ], + dimensions=[ + {"field": "Account_Name", "label": "Account Name"}, + {"field": "Close_Date_Day", "label": "Close Date Day"}, # Should be excluded + {"field": "Stage", "label": "Stage"}, + ], + dates=[], + ) + + fields = service._extract_fields_from_xmd(xmd) + + assert "Amount" in fields + assert "Account_Name" in fields + assert "Stage" in fields + assert "Quantity_epoch" not in fields + assert "Close_Date_Day" not in fields + + +class TestDashboardServiceIntegration: + """Integration tests for DashboardService.""" + + @pytest.mark.asyncio + async def test_backup_dashboard(self, mock_client, settings): + """Test dashboard backup.""" + service = DashboardService(mock_client, settings=settings) + + from datetime import datetime + mock_response = MockResponse({ + "id": "0FK000000000001", + "name": "TestDashboard", + "label": "Test Dashboard", + "state": {"widgets": []}, + "created_date": datetime.utcnow().isoformat(), + "created_by_id": "005000000000001", + "last_modified_date": datetime.utcnow().isoformat(), + "last_modified_by_id": "005000000000001", + }) + mock_client._client.request.return_value = mock_response + + backup = await service.backup_dashboard("0FK000000000001") + + assert backup.dashboard_id == "0FK000000000001" + assert backup.dashboard_name == "TestDashboard" + assert "widgets" in backup.json_definition.get("state", {}) + + +class TestDataflowServiceIntegration: + """Integration tests for DataflowService.""" + + @pytest.mark.asyncio + async def test_wait_for_dataflow_job(self, mock_client, settings): + """Test waiting for dataflow job completion.""" + service = DataflowService(mock_client, settings=settings) + + # Mock job status progression + call_count = 0 + + async def mock_list_jobs(): + nonlocal call_count + call_count += 1 + if call_count < 3: + return { + "dataflowjobs": [{ + "id": "03D000000000001", + "dataflow_id": "03C000000000001", + "dataflow_name": "TestDataflow", + "command": "start", + "status": "Running", + }] + } + return { + "dataflowjobs": [{ + "id": "03D000000000001", + "dataflow_id": "03C000000000001", + "dataflow_name": "TestDataflow", + "command": "start", + "status": "Success", + }] + } + + mock_client.list_dataflow_jobs = mock_list_jobs + + job = await service.wait_for_dataflow_job("03D000000000001", poll_interval=0, timeout=10) + + assert job.status == "Success" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..ea3f8b9 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package.""" diff --git a/tests/unit/test_auth_schemas.py b/tests/unit/test_auth_schemas.py new file mode 100644 index 0000000..611762d --- /dev/null +++ b/tests/unit/test_auth_schemas.py @@ -0,0 +1,127 @@ +"""Unit tests for auth schemas.""" + + +from tcrm_toolkit.core.models import ( + ConnectedAppConfig, + DeviceAuthorizationResponse, + DeviceFlowConfig, + OAuthToken, + WebOAuthConfig, +) + + +class TestOAuthToken: + """Tests for OAuthToken model.""" + + def test_valid_token(self): + """Test valid OAuth token.""" + token = OAuthToken( + access_token="test_access_token", + refresh_token="test_refresh_token", + instance_url="https://na100.salesforce.com", + id="https://login.salesforce.com/id/00Dxx0000001gXZ/005xx000001Sv6A", + ) + assert token.access_token == "test_access_token" + assert token.token_type == "Bearer" + + def test_token_with_optional_fields(self): + """Test OAuth token with optional fields.""" + token = OAuthToken( + access_token="test_access_token", + instance_url="https://na100.salesforce.com", + id="test_id", + issued_at="1234567890", + signature="test_sig", + scope="api refresh_token", + ) + assert token.issued_at == "1234567890" + assert token.scope == "api refresh_token" + + +class TestConnectedAppConfig: + """Tests for ConnectedAppConfig model.""" + + def test_valid_config(self): + """Test valid Connected App config.""" + config = ConnectedAppConfig( + client_id="test_client_id", + client_secret="test_client_secret", + username="test@example.com", + ) + assert config.client_id == "test_client_id" + assert config.domain == "login" + + def test_config_with_custom_domain(self): + """Test config with custom domain.""" + config = ConnectedAppConfig( + client_id="test_client_id", + client_secret="test_client_secret", + username="test@example.com", + domain="test", + ) + assert config.domain == "test" + + +class TestWebOAuthConfig: + """Tests for WebOAuthConfig model.""" + + def test_valid_config(self): + """Test valid Web OAuth config.""" + config = WebOAuthConfig( + client_id="test_client_id", + client_secret="test_client_secret", + redirect_uri="http://localhost:8080/callback", + ) + assert config.client_id == "test_client_id" + assert config.scopes == ["api", "refresh_token", "web"] + + def test_custom_scopes(self): + """Test config with custom scopes.""" + config = WebOAuthConfig( + client_id="test_client_id", + client_secret="test_client_secret", + redirect_uri="http://localhost:8080/callback", + scopes=["api", "full"], + ) + assert config.scopes == ["api", "full"] + + +class TestDeviceFlowConfig: + """Tests for DeviceFlowConfig model.""" + + def test_valid_config(self): + """Test valid Device Flow config.""" + config = DeviceFlowConfig( + client_id="test_client_id", + ) + assert config.client_id == "test_client_id" + assert config.domain == "login" + + +class TestDeviceAuthorizationResponse: + """Tests for DeviceAuthorizationResponse model.""" + + def test_valid_response(self): + """Test valid device authorization response.""" + response = DeviceAuthorizationResponse( + device_code="test_device_code", + user_code="TEST-CODE", + verification_uri="https://test.salesforce.com/device", + expires_in=1800, + interval=5, + ) + assert response.device_code == "test_device_code" + assert response.user_code == "TEST-CODE" + assert response.interval == 5 + + def test_optional_verification_uri_complete(self): + """Test response with optional verification_uri_complete.""" + response = DeviceAuthorizationResponse( + device_code="test_device_code", + user_code="TEST-CODE", + verification_uri="https://test.salesforce.com/device", + verification_uri_complete="https://test.salesforce.com/device?user_code=TEST-CODE", + expires_in=1800, + interval=5, + ) + assert response.verification_uri_complete is not None diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..c35c018 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,193 @@ +"""Unit tests for Salesforce client.""" + +import base64 +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from tcrm_toolkit.core.client import SalesforceClient +from tcrm_toolkit.core.config import Settings +from tcrm_toolkit.core.exceptions import ( + SalesforceAPIError, + SalesforceAuthError, + SalesforceNotFoundError, + SalesforceRateLimitError, +) + + +@pytest.fixture +def test_settings(): + """Create test settings with valid keys.""" + encryption_key = base64.urlsafe_b64encode(b"x" * 32).decode() + jwt_secret = base64.urlsafe_b64encode(b"y" * 32).decode() + # Use model_construct to bypass .env file loading and validation + return Settings.model_construct( + encryption_key=encryption_key, + jwt_secret_key=jwt_secret, + sf_api_version="v60.0", + sf_default_domain="test.salesforce.com", + ) + + +@pytest.fixture +def client(test_settings): + """Create test client.""" + return SalesforceClient( + access_token="test_token", + instance_url="https://test.salesforce.com", + settings=test_settings, + ) + + +@pytest.fixture +def retry_test_settings(): + """Create test settings with valid keys for retry tests.""" + encryption_key = base64.urlsafe_b64encode(b"x" * 32).decode() + jwt_secret = base64.urlsafe_b64encode(b"y" * 32).decode() + # Use model_construct to bypass .env file loading and validation + return Settings.model_construct( + encryption_key=encryption_key, + jwt_secret_key=jwt_secret, + sf_api_version="v60.0", + sf_default_domain="test.salesforce.com", + ) + + +@pytest.fixture +def retry_client(retry_test_settings): + return SalesforceClient( + access_token="test_token", + instance_url="https://test.salesforce.com", + settings=retry_test_settings, + ) + + +class TestSalesforceClient: + """Tests for SalesforceClient.""" + + def test_base_url(self, client): + """Test base URL construction.""" + assert client.base_url == "https://test.salesforce.com/services/data/v60.0" + assert client.wave_base_url == "https://test.salesforce.com/services/data/v60.0/wave" + + def test_build_url(self, client): + """Test URL building.""" + assert client._build_url("/query") == "https://test.salesforce.com/services/data/v60.0/query" + assert client._build_url("query") == "https://test.salesforce.com/services/data/v60.0/query" + assert client._build_url("https://other.com/api") == "https://other.com/api" + + @pytest.mark.asyncio + async def test_handle_response_401(self, client): + """Test handling 401 response.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 401 + + with pytest.raises(SalesforceAuthError): + client._handle_response(response) + + @pytest.mark.asyncio + async def test_handle_response_404(self, client): + """Test handling 404 response.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 404 + + with pytest.raises(SalesforceNotFoundError): + client._handle_response(response) + + @pytest.mark.asyncio + async def test_handle_response_429(self, client): + """Test handling 429 response.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 429 + response.headers = {"Retry-After": "30"} + + with pytest.raises(SalesforceRateLimitError) as exc_info: + client._handle_response(response) + assert exc_info.value.retry_after == 30 + + @pytest.mark.asyncio + async def test_handle_response_500(self, client): + """Test handling 500 response.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 500 + + with pytest.raises(SalesforceAPIError) as exc_info: + client._handle_response(response) + assert exc_info.value.status_code == 500 + + @pytest.mark.asyncio + async def test_handle_response_400_with_json(self, client): + """Test handling 400 response with JSON error.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 400 + # Salesforce returns errors as a list + response.json.return_value = [{ + "message": "Invalid query", + "errorCode": "MALFORMED_QUERY", + }] + + with pytest.raises(SalesforceAPIError) as exc_info: + client._handle_response(response) + assert "MALFORMED_QUERY" in str(exc_info.value) + assert "Invalid query" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_close(self, client): + """Test client close.""" + # Mock the internal client + mock_client = AsyncMock() + client._client = mock_client + + await client.close() + + mock_client.aclose.assert_called_once() + assert client._client is None + + @pytest.mark.asyncio + async def test_context_manager(self, client): + """Test async context manager.""" + mock_client = AsyncMock() + client._client = mock_client + + async with client as c: + assert c is client + + mock_client.aclose.assert_called_once() + + +class TestSalesforceClientRetry: + """Tests for retry logic.""" + + @pytest.mark.asyncio + async def test_retry_on_timeout(self, retry_client): + """Test retry on timeout.""" + mock_client = AsyncMock() + retry_client._client = mock_client + + # First two calls timeout, third succeeds + mock_client.request.side_effect = [ + httpx.TimeoutException("Timeout"), + httpx.TimeoutException("Timeout"), + MagicMock(status_code=200, json=lambda: {"result": "success"}), + ] + + # This would test the retry logic, but it's complex to test fully + # without more mocking. The key point is that tenacity is configured. + assert retry_client.retry_client is not None + + @pytest.mark.asyncio + async def test_no_retry_on_401(self, retry_client): + """Test that 401 is not retried (handled as auth error).""" + mock_client = AsyncMock() + retry_client._client = mock_client + + response = MagicMock(spec=httpx.Response) + response.status_code = 401 + mock_client.request.return_value = response + + with pytest.raises(SalesforceAuthError): + await retry_client.get("/test") + + # Should only be called once (no retry on 401) + assert mock_client.request.call_count == 1 diff --git a/tests/unit/test_crypto.py b/tests/unit/test_crypto.py new file mode 100644 index 0000000..62f754a --- /dev/null +++ b/tests/unit/test_crypto.py @@ -0,0 +1,100 @@ +"""Unit tests for crypto module.""" + +import base64 + +import pytest + +from tcrm_toolkit.core.config import Settings +from tcrm_toolkit.core.crypto import CryptoManager, EncryptedData + + +@pytest.fixture +def test_settings(): + """Create test settings with valid keys.""" + encryption_key = base64.urlsafe_b64encode(b"x" * 32).decode() + jwt_secret = base64.urlsafe_b64encode(b"y" * 32).decode() + # Use model_construct to bypass .env file loading and validation + return Settings.model_construct( + encryption_key=encryption_key, + jwt_secret_key=jwt_secret, + ) + + +@pytest.fixture +def crypto_manager(test_settings): + """Create a crypto manager for testing.""" + return CryptoManager(test_settings.encryption_key) + + +class TestCryptoManager: + """Tests for CryptoManager.""" + + def test_encrypt_decrypt_roundtrip(self, crypto_manager): + """Test that encrypt/decrypt works correctly.""" + plaintext = "test secret data" + + encrypted = crypto_manager.encrypt(plaintext) + decrypted = crypto_manager.decrypt(encrypted) + + assert decrypted == plaintext + + def test_encrypt_produces_different_ciphertext(self, crypto_manager): + """Test that encrypting same plaintext twice produces different ciphertext.""" + plaintext = "test secret data" + + encrypted1 = crypto_manager.encrypt(plaintext) + encrypted2 = crypto_manager.encrypt(plaintext) + + # Different salts should produce different ciphertext + assert encrypted1.ciphertext != encrypted2.ciphertext + assert encrypted1.salt != encrypted2.salt + + def test_decrypt_with_wrong_key_fails(self, test_settings): + """Test that decrypting with wrong key fails.""" + crypto1 = CryptoManager(test_settings.encryption_key) + crypto2 = CryptoManager(base64.urlsafe_b64encode(b"z" * 32).decode()) + + plaintext = "test secret data" + encrypted = crypto1.encrypt(plaintext) + + with pytest.raises(Exception): + crypto2.decrypt(encrypted) + + def test_encrypt_json_decrypt_json(self, crypto_manager): + """Test JSON encryption/decryption.""" + data = {"key": "value", "number": 42, "nested": {"a": 1}} + + encrypted = crypto_manager.encrypt_json(data) + decrypted = crypto_manager.decrypt_json(encrypted) + + assert decrypted == data + + def test_encrypted_data_serialization(self): + """Test EncryptedData JSON serialization.""" + encrypted = EncryptedData( + ciphertext="dGVzdA==", + salt="c2FsdA==", + iterations=100000, + ) + + json_str = encrypted.to_json() + restored = EncryptedData.from_json(json_str) + + assert restored.ciphertext == encrypted.ciphertext + assert restored.salt == encrypted.salt + assert restored.iterations == encrypted.iterations + + def test_generate_encryption_key(self): + """Test encryption key generation.""" + from tcrm_toolkit.core.config import generate_encryption_key + + key = generate_encryption_key() + decoded = base64.urlsafe_b64decode(key + "=" * (-len(key) % 4)) + assert len(decoded) == 32 + + def test_generate_jwt_secret(self): + """Test JWT secret generation.""" + from tcrm_toolkit.core.config import generate_jwt_secret + + secret = generate_jwt_secret() + assert len(secret) >= 32 diff --git a/tests/unit/test_interactive.py b/tests/unit/test_interactive.py new file mode 100644 index 0000000..d4e9553 --- /dev/null +++ b/tests/unit/test_interactive.py @@ -0,0 +1,233 @@ +"""Unit tests for Phase 1 Interactive TUI components (SafetyMonitor, SessionManager).""" + +import base64 +from datetime import datetime + +import pytest + +from tcrm_toolkit.core.config import Settings +from tcrm_toolkit.interactive.safety import ( + CheckName, + CheckResult, + RiskLevel, + SafetyMonitor, + SafetyResult, +) +from tcrm_toolkit.interactive.session import SessionManager + + +@pytest.fixture +def settings(): + encryption_key = base64.urlsafe_b64encode(b"x" * 32).decode() + jwt_secret = base64.urlsafe_b64encode(b"y" * 32).decode() + return Settings.model_construct( + encryption_key=encryption_key, + jwt_secret_key=jwt_secret, + safety_allowlist_ips=["192.168.1.1"], + ) + + +@pytest.mark.asyncio +async def test_safety_result_post_init(): + # Test safe result + res = SafetyResult(is_safe=True, checks={ + CheckName.DNS_LEAK: CheckResult(name=CheckName.DNS_LEAK, passed=True, details="OK", risk_level=RiskLevel.SAFE) + }) + assert res.is_safe is True + assert res.risk_level == RiskLevel.SAFE + + # Test critical risk + res_crit = SafetyResult(is_safe=True, checks={ + CheckName.IP_REPUTATION: CheckResult(name=CheckName.IP_REPUTATION, passed=False, details="VPN detected", risk_level=RiskLevel.CRITICAL) + }) + assert res_crit.is_safe is False + assert res_crit.risk_level == RiskLevel.CRITICAL + assert "ip_reputation" in res_crit.details + + +@pytest.mark.asyncio +async def test_safety_monitor_allowlist(settings): + monitor = SafetyMonitor(settings) + # Mock _get_current_ip to return allowlisted IP + monitor._get_current_ip = async_return_value("192.168.1.1") + + result = await monitor._check_ip_reputation() + assert result.passed is True + assert "allowlist" in result.details + await monitor.close() + + +def async_return_value(val): + async def factory(*args, **kwargs): + return val + return factory + + +@pytest.mark.asyncio +async def test_session_manager_init(settings): + session = SessionManager(settings=settings) + assert session.current_alias == "default" + assert session.current_org is None + orgs = session.list_orgs() + assert isinstance(orgs, list) + await session.close() + + +def test_data_browser_and_context_menu(): + from tcrm_toolkit.core.models import DataflowJob + from tcrm_toolkit.interactive.widgets.context_menu import ContextMenu + from tcrm_toolkit.interactive.widgets.data_table import ColumnConfig, DataBrowser + from tcrm_toolkit.interactive.widgets.detail_panel import DetailPanel + + cols = [ColumnConfig(key="id", title="ID"), ColumnConfig(key="name", title="Name")] + async def dummy_load(offset, limit, search, sort): + return [{"id": "1", "name": "Test"}], 1 + + browser = DataBrowser( + columns=cols, + load_data=dummy_load, + get_row_id=lambda r: r["id"], + get_row_data=lambda r: r, + ) + assert browser.title == "Data Browser" + + menu = ContextMenu([("View", "view"), ("Delete", "del")], 10, 10) + assert len(menu.actions) == 2 + + detail = DetailPanel() + job = DataflowJob( + id="job_1", + dataflow_id="df_1", + dataflow_name="df_1", + command="start", + status="Success", + start_time=datetime(2026, 7, 23, 10, 0), + end_time=datetime(2026, 7, 23, 10, 5), + ) + detail.show_dataflow_job(job) + assert "job_1" in str(detail._content.render()) + + +@pytest.mark.asyncio +async def test_task_runner(): + import asyncio + import tempfile + from pathlib import Path + + import pandas as pd + + from tcrm_toolkit.interactive.tasks import TaskRunner, TaskStatus, merge_csv_chunks + + runner = TaskRunner(max_concurrent=2) + + async def dummy_coro(): + await asyncio.sleep(0.01) + return "success" + + result = await runner.run_task(dummy_coro, name="Dummy") + assert result.status == TaskStatus.COMPLETED + assert result.result == "success" + + history = runner.get_history() + assert len(history) == 1 + + # Test process pool with merge_csv_chunks + with tempfile.TemporaryDirectory() as tmpdir: + p1 = Path(tmpdir) / "c1.csv" + p2 = Path(tmpdir) / "c2.csv" + out = Path(tmpdir) / "out.csv" + pd.DataFrame({"a": [1, 2]}).to_csv(p1, index=False) + pd.DataFrame({"a": [3, 4]}).to_csv(p2, index=False) + + res = await runner.run_in_process_pool(merge_csv_chunks, [str(p1), str(p2)], str(out)) + assert res["total_rows"] == 4 + assert out.exists() + + await runner.close() + + +def test_phase_4_polish_components(): + import tempfile + from pathlib import Path + + from tcrm_toolkit.interactive.config_manager import ConfigManager + from tcrm_toolkit.interactive.notifications import NotificationManager + from tcrm_toolkit.interactive.screens.help_screen import HelpScreen + from tcrm_toolkit.interactive.widgets.command_palette import CommandPaletteScreen + from tcrm_toolkit.interactive.window_manager import WindowManager + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + + # Test Config & ConfigManager + cfg_mgr = ConfigManager(tmp_path) + cfg = cfg_mgr.load() + assert cfg.theme == "dark" + cfg.theme = "light" + cfg_mgr.save(cfg) + + cfg_mgr2 = ConfigManager(tmp_path) + cfg2 = cfg_mgr2.load() + assert cfg2.theme == "light" + + # Test WindowManager + win_mgr = WindowManager(tmp_path) + win_mgr.set("sidebar_width", 30) + assert win_mgr.get("sidebar_width") == 30 + + # Test NotificationManager + notif_mgr = NotificationManager(max_history=5) + rec = notif_mgr.record("Test alert", severity="warning") + assert rec.message == "Test alert" + assert rec.severity == "warning" + assert len(notif_mgr.get_history()) == 1 + + # Test CommandPaletteScreen init + palette = CommandPaletteScreen([("Extract Dataset", "extract"), ("Upload Dataset", "upload")]) + assert len(palette.commands) == 2 + + # Test HelpScreen init + help_screen = HelpScreen() + assert help_screen is not None + + # Test column widths persistence via WindowManager + win_mgr.set("col_widths_datasets", {"id": 15, "name": 35}) + assert win_mgr.get("col_widths_datasets") == {"id": 15, "name": 35} + + +def test_phase_3_operations_and_widgets(settings): + from tcrm_toolkit.interactive.operations.dashboard_backup import DashboardBackupManager + from tcrm_toolkit.interactive.operations.dataflow_control import DataflowController + from tcrm_toolkit.interactive.operations.dataset_extract import ParallelDatasetExtractor + from tcrm_toolkit.interactive.operations.dataset_upload import ParallelDatasetUploader + from tcrm_toolkit.interactive.tasks import TaskRunner + from tcrm_toolkit.interactive.widgets.progress_panel import ProgressPanel + from tcrm_toolkit.interactive.widgets.task_history import TaskHistory, TaskHistoryPanel + + runner = TaskRunner() + progress_panel = ProgressPanel(runner) + assert progress_panel is not None + + history_panel = TaskHistory(runner) + assert history_panel is not None + assert TaskHistoryPanel is TaskHistory + + class DummySession: + settings = settings + + session = DummySession() + extractor = ParallelDatasetExtractor(session, runner) + assert extractor is not None + + uploader = ParallelDatasetUploader(session, runner) + assert uploader is not None + + backup_mgr = DashboardBackupManager(session, runner) + assert backup_mgr is not None + + df_controller = DataflowController(session, runner) + assert df_controller is not None + + + + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ba85a71 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2591 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiohttp-jinja2" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057, upload-time = "2023-11-18T15:30:52.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736, upload-time = "2023-11-18T15:30:50.743Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[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, 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, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +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, 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, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +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, 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, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[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, 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, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +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, 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, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +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, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +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, 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, 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, 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, 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 = "joserfc" +version = "1.7.4" +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, 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, upload-time = "2026-07-19T15:43:01.299Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { 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, 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, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/3e/79f35b8c31a1881893b7e62be80b2573f06e38db47c33065749293ee1b97/linkify_it_py-2.1.1.tar.gz", hash = "sha256:a78f40fee177eb912e9d2375074108378523c38d3fde5d3ee804f465b6cfbfee", size = 30889, upload-time = "2026-08-24T17:16:57.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/3d/e34b19cd144071c583317268c4feb2c59c03ac57eef69753410c7abb11c0/linkify_it_py-2.1.1-py3-none-any.whl", hash = "sha256:8539a6b470efce90ba9b69e39b848e5b15b7ad89f7f98ca17d3532c243f987dc", size = 20532, upload-time = "2026-08-24T17:16:55.965Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +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, 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, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[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/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { 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]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[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, 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, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[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, 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, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/a05ba8f84c5951c9aec2a19c1c81f6c4a67b8bec80af604ac5b23ccfa019/msgpack-1.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d7fb25b4442fae0cb2590272d06ab4f6caa526ee36a994edb81e946b874813e", size = 83498, upload-time = "2026-08-27T10:01:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/0f/df/e20bcf5c149890545334743b212eb4b82e1a25fe0a34f99753a1755bfab5/msgpack-1.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fe374ba76eb0ecca13a1703daa8fa85825a6ddddbb52d4c1a732fa524194683", size = 83896, upload-time = "2026-08-27T10:01:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c3/00dcd902d66a641b9ba350783feb482ea5c1ca4a7ff6629db0c10c0ea982/msgpack-1.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9b0c1f2aa7b0026b4bd50718100e8b04175e4f36e160aa852502377b5e572e7", size = 413259, upload-time = "2026-08-27T10:01:53.296Z" }, + { url = "https://files.pythonhosted.org/packages/93/15/17374efe9793f5332c7d4727ab40539f95a1dc9df653531795daca8c4281/msgpack-1.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f11e09f10210a91c169e39c7a5a1f9090eaa73ad75555fafad5023c3053c47ba", size = 422907, upload-time = "2026-08-27T10:01:54.786Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/2b567d684f912fedcefe3f7c37de604716ffa99336bd432688f9f040df92/msgpack-1.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b1415d02e9bf722672af8a90f90813265a0cd0b14163187261e54a5592bc949", size = 389248, upload-time = "2026-08-27T10:01:56.55Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/d8533fed473cc3e309a701e851d0e5fe36ada5552a3899025f5c69fbe877/msgpack-1.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:42fd9260416885b4815caca5bdd14dfd5dda6cdade732d6c09104ef8f6228761", size = 407099, upload-time = "2026-08-27T10:01:58.357Z" }, + { url = "https://files.pythonhosted.org/packages/d6/1b/57906337bfee0ead554571dc203ea17c3fad26d51e5eca6271ecd983f73b/msgpack-1.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:336525cc2688e43ea77dfb1a4ce012c8cde561835913801dbfcfdcf4111d8abb", size = 387201, upload-time = "2026-08-27T10:02:00.109Z" }, + { url = "https://files.pythonhosted.org/packages/de/0f/5d1e6d68e516621697a9262b24917d678793e838cf3f331ed4656b3e959d/msgpack-1.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cdb6cc6e1127d15879c47a8b3270716243da82d3e7feab1f5946872c75b3d60f", size = 420765, upload-time = "2026-08-27T10:02:01.573Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a6/07f9a4f3324d55c3567ab2a7e8d5325291bc95a31a374bb390a21b7c4e24/msgpack-1.2.2-cp311-cp311-win32.whl", hash = "sha256:cf66fb38703e61a486b01b56d43bb1f50698fbe99b6bd90feba10f24fab60b3b", size = 64785, upload-time = "2026-08-27T10:02:03.01Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f748f0d59f355d196e71a0b32d48d386a9bd311f94d954e666cf7e5b2572/msgpack-1.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:0883a1578168929fd1640fbbc4614773f1a130e419a8a817dc2918d9af1b651c", size = 71258, upload-time = "2026-08-27T10:02:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/10d979c4e76b18a9b9ebbd6499ff863474ffe5955028ea27e09b66f6833c/msgpack-1.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:4955accbd87f27beebef5f3ecc27503aa74cb016fb4f640868e749fd93194a35", size = 65860, upload-time = "2026-08-27T10:02:05.735Z" }, + { url = "https://files.pythonhosted.org/packages/31/78/90c15bebb1a72667349ca62d4507e9d9369e7f8f76b95f490b823d3622e5/msgpack-1.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc", size = 84275, upload-time = "2026-08-27T10:02:07.106Z" }, + { url = "https://files.pythonhosted.org/packages/88/88/c2b6d8e81571da87aa232c0e34a3f3a0e618e6235892065ec82d1d81fc7a/msgpack-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3", size = 83970, upload-time = "2026-08-27T10:02:08.488Z" }, + { url = "https://files.pythonhosted.org/packages/da/c0/d3ede9f5d16acb4c05a9281859f1e99ef9f877a928eb78454c37f70db001/msgpack-1.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba", size = 409401, upload-time = "2026-08-27T10:02:09.877Z" }, + { url = "https://files.pythonhosted.org/packages/41/f0/29f591bea185616cf417645ac03bd3ad9b317483ad8572160e325f7fe777/msgpack-1.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923", size = 420619, upload-time = "2026-08-27T10:02:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8e/c70c8c9180c5ddf4440eb8658ebead98e22e7686fbf84f6b165031430750/msgpack-1.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f", size = 379747, upload-time = "2026-08-27T10:02:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/50/9a/f10ce11fa62700c9ab87a22e65b9ca272f7f673ddd31aeb2de6ae272ad35/msgpack-1.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367", size = 398944, upload-time = "2026-08-27T10:02:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/82/fe/d7be978456ff8552e69a8e270d882e7530e01513c096b293d83df03753ea/msgpack-1.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc", size = 373979, upload-time = "2026-08-27T10:02:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/be/af/91b0d8d3fb3063e259daee3ea8515cea6282f68f4b0e5f0b6fea25762c6e/msgpack-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3", size = 417781, upload-time = "2026-08-27T10:02:18.293Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3c/ce8e9efe1fd9e95c78b3705e4300ba7feba3dc6c00fb76259895db155518/msgpack-1.2.2-cp312-cp312-win32.whl", hash = "sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4", size = 65267, upload-time = "2026-08-27T10:02:19.869Z" }, + { url = "https://files.pythonhosted.org/packages/85/98/a33b8b4af14e3476bb0da1b8c36ef7a0f28dcf95db1c5e68ff88cb89d591/msgpack-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3", size = 72275, upload-time = "2026-08-27T10:02:21.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/2f323a33a6aba5bd4b2d8b430e4fab21d92cd91c093b49ee287bc166ee54/msgpack-1.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640", size = 65488, upload-time = "2026-08-27T10:02:22.575Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" }, + { url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" }, + { url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/3e92c403346652cabd08cb8faceef847bae917ea3b3c81b64a5b6d09ed41/msgpack-1.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04", size = 84315, upload-time = "2026-08-27T10:02:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/8efe6dd96a12ab043930cb4cffb40b6e7f061491d6ec7a3d2b75ef1fda42/msgpack-1.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77", size = 84634, upload-time = "2026-08-27T10:02:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/996573095bf7b038c04dd65ddbc4f1a4d381b0f7a44ff9186f3c7b8325c2/msgpack-1.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe", size = 404194, upload-time = "2026-08-27T10:02:44.096Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4e/46f5a5d949dbd054dab60cb15aac7ac6ae6774c134532893414689bf2f53/msgpack-1.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f", size = 412343, upload-time = "2026-08-27T10:02:45.747Z" }, + { url = "https://files.pythonhosted.org/packages/da/e8/739a94197358a313307e6e9e7d8d22ef66add39222de911a44161aa96920/msgpack-1.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea", size = 372620, upload-time = "2026-08-27T10:02:47.578Z" }, + { url = "https://files.pythonhosted.org/packages/03/d4/09b92e1fcdccea9466bfae45455367ac52362ae445d96a602e51b7a8df73/msgpack-1.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b", size = 394603, upload-time = "2026-08-27T10:02:49.172Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/d11bd6f258a60703dcdc7a3772818ad0c2f602ee4c2acfb24088c6c3ebc3/msgpack-1.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5", size = 372666, upload-time = "2026-08-27T10:02:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/fbbbac0c6e5fbb9d51abc23e3b5fe8620f5c01e0588797cf664a623bb9e1/msgpack-1.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54", size = 410889, upload-time = "2026-08-27T10:02:52.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/60/8366558da954095e04e7fbc351f9387d87a682feaee9a235ceda966f794b/msgpack-1.2.2-cp314-cp314-win32.whl", hash = "sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248", size = 66774, upload-time = "2026-08-27T10:02:53.984Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3d/1ce873c8057c65e4fbb076ffe1c99c9ae39d90a00a2540d7b06c652a292f/msgpack-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc", size = 73424, upload-time = "2026-08-27T10:02:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/d5/55/e36f2a33e38657f33850d74e0bf256838a0d45802c298cc501a32bffcc08/msgpack-1.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8", size = 67657, upload-time = "2026-08-27T10:02:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/64/58/7e764b957bae80ae281a9cb28761068c8bae8d5c6ac0873e43cc69d176c7/msgpack-1.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650", size = 86594, upload-time = "2026-08-27T10:02:57.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f0/250f5985b6ee533e60d357571a808aaae03c54118294dc3db7158e27feb1/msgpack-1.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c", size = 87374, upload-time = "2026-08-27T10:02:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2c/126ec8f187877c5f688631c543d1d3a3d75b2e66b83fb9de3ed7c13a39b6/msgpack-1.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3", size = 428157, upload-time = "2026-08-27T10:03:00.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d2d81d50aaedb14147d01f22094185794db3ad8a8791b60afacba0627c89/msgpack-1.2.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3", size = 426669, upload-time = "2026-08-27T10:03:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fc/f7d484ee5b572719608e7ffad569bea22ff11309a96ca2fae85eec94226b/msgpack-1.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22", size = 380625, upload-time = "2026-08-27T10:03:04.244Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c4/b924cbd5516676f4e612329f18602a833bd055ffbe27f808eeba0f01bfea/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839", size = 411328, upload-time = "2026-08-27T10:03:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/27/9d/0c1d9683a951a80f270c3b7dac1022c18b9307617344dd44d904135d5e12/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929", size = 377892, upload-time = "2026-08-27T10:03:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/bf22338cdd22e0b40c8f28468cea5f3d9c320244c095d8303364bc012c41/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7", size = 419426, upload-time = "2026-08-27T10:03:09Z" }, + { url = "https://files.pythonhosted.org/packages/7d/42/6d02c19a01abd8d7ce817c321d2ee6af1a8e24d584dca619d1b6576a83bf/msgpack-1.2.2-cp314-cp314t-win32.whl", hash = "sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e", size = 71810, upload-time = "2026-08-27T10:03:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/fda3a204415dab0a8c0db5461ef7205416ea52bd8581c5cafd361be07f3b/msgpack-1.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36", size = 78919, upload-time = "2026-08-27T10:03:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/63/d4/4b4b0ef25a86deca91feaf7252ca885ba4f2ada40461379120122a04fe96/msgpack-1.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f", size = 71925, upload-time = "2026-08-27T10:03:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/4b44bc8f3243ef8cf9cb5368c17a299d45b9df858f6dfdd98a0482dbbb37/msgpack-1.2.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5", size = 84293, upload-time = "2026-08-27T10:03:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/80/05/c992bb65744665a41b5bf531fc0e1619bae0901f57738228ded90023c151/msgpack-1.2.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986", size = 84490, upload-time = "2026-08-27T10:03:16.12Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bf/7f53b9e6709a4df7f9b9b81dc65f9dfaa32caf65bee94986ec2cb8fa07f1/msgpack-1.2.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516", size = 405332, upload-time = "2026-08-27T10:03:17.692Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5a/305c4dca14b50d0b51fb88ef04ec125b8f0be3e2ce730dcc62dbaa651cc5/msgpack-1.2.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21", size = 416798, upload-time = "2026-08-27T10:03:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/a645102b4cdfd9a94201cac4e900e9c1429fc16d86aa311c06eef82528c9/msgpack-1.2.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9", size = 377312, upload-time = "2026-08-27T10:03:20.928Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/c56d8d086d3fb1077bb48092b158b5ea2eee08b279e10c191275f13bc980/msgpack-1.2.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a", size = 395182, upload-time = "2026-08-27T10:03:22.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b5/3d46ba367a565e536d8d2a61eebcee71b1dc803da3ce74a22313b573d6fa/msgpack-1.2.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5", size = 377945, upload-time = "2026-08-27T10:03:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2c/d5d2df273ed5306357da25b69400fd8d7a53c4d87d8976604b677484d61c/msgpack-1.2.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b", size = 413341, upload-time = "2026-08-27T10:03:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fb/32613bced3cad47b40b1b73dd04d687121349d83f748efc2575929121903/msgpack-1.2.2-cp315-cp315-win32.whl", hash = "sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf", size = 66730, upload-time = "2026-08-27T10:03:27.294Z" }, + { url = "https://files.pythonhosted.org/packages/74/56/d86171f7251015e9312e5a7f9fdd4cf89752fc2114b88fed453d2a040c66/msgpack-1.2.2-cp315-cp315-win_amd64.whl", hash = "sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff", size = 73477, upload-time = "2026-08-27T10:03:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/13/1a/56b90f6defef61700b86baca3637c15f62ac0f9b21ab0f16613ab9d1f101/msgpack-1.2.2-cp315-cp315-win_arm64.whl", hash = "sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808", size = 67660, upload-time = "2026-08-27T10:03:29.895Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/12751ca0d8ec874701b54c392c2b19f51af8dd1de40a92a10e356f0aaf58/msgpack-1.2.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8", size = 86462, upload-time = "2026-08-27T10:03:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/cf6d12a3d709fe5f9771dd917c35e6ebcd55597a5b792287382fde056c95/msgpack-1.2.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84", size = 87412, upload-time = "2026-08-27T10:03:32.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0d/0aac5752d1708dcb458f8754db34a4999514db3df2d2b798b9381293f638/msgpack-1.2.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b", size = 422057, upload-time = "2026-08-27T10:03:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/81/30/70f281a3685b04aaf235a5237da11b978a02a865a5a479186205177ad676/msgpack-1.2.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782", size = 422696, upload-time = "2026-08-27T10:03:35.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/f76e8425efb0aa38988cd778ae290bfa120491d80d26872d88bb52fedb3f/msgpack-1.2.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f", size = 376495, upload-time = "2026-08-27T10:03:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/0809aa9b52b2868f7d01862dc14073708f0440421a65197b48453480034c/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695", size = 404683, upload-time = "2026-08-27T10:03:38.87Z" }, + { url = "https://files.pythonhosted.org/packages/02/d2/4e5ac915ba120172d210ef00165c5e6276c8a65db3a4a5cf36e946b83e23/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23", size = 375087, upload-time = "2026-08-27T10:03:40.486Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e3/8051d53e5495c87c6cf27eb42fb680361017037f87f322bdaf525f71e4a2/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212", size = 414421, upload-time = "2026-08-27T10:03:42.308Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4e/13783aa7c17414d7186c72c49bc718366f75e49f0ea58d4f81cb63ac3187/msgpack-1.2.2-cp315-cp315t-win32.whl", hash = "sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc", size = 71790, upload-time = "2026-08-27T10:03:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/1d02994c7ae2603c98100984428ff0f67443572133bc18eca6058f732c1b/msgpack-1.2.2-cp315-cp315t-win_amd64.whl", hash = "sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d", size = 78766, upload-time = "2026-08-27T10:03:45.036Z" }, + { url = "https://files.pythonhosted.org/packages/60/54/89ed16e6f966a050dc78b0e94a545025211b07ce9f4bdfe07dff70c03fc2/msgpack-1.2.2-cp315-cp315t-win_arm64.whl", hash = "sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754", size = 71819, upload-time = "2026-08-27T10:03:46.375Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[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 = "platformdirs" +version = "4.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +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, 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, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[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, 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, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { 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, 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, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +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, 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, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[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, 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, 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, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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 = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +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, 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, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +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, 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, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "tcrm-toolkit" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "authlib" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "keyring" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "rich" }, + { name = "structlog" }, + { name = "tenacity" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] +interactive = [ + { name = "httpx" }, + { name = "textual" }, + { name = "textual-dev" }, +] + +[package.metadata] +requires-dist = [ + { name = "authlib", specifier = ">=1.3.0" }, + { name = "cryptography", specifier = ">=42.0.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "httpx", marker = "extra == 'interactive'", specifier = ">=0.27.0" }, + { name = "keyring", specifier = ">=24.3.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, + { name = "pandas", specifier = ">=2.2.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7.0" }, + { name = "pydantic", specifier = ">=2.7.0" }, + { name = "pydantic-settings", specifier = ">=2.3.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, + { name = "rich", specifier = ">=13.7.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" }, + { name = "structlog", specifier = ">=24.1.0" }, + { name = "tenacity", specifier = ">=8.2.0" }, + { name = "textual", marker = "extra == 'interactive'", specifier = ">=0.52.0" }, + { name = "textual-dev", marker = "extra == 'interactive'", specifier = ">=0.1.0" }, + { name = "typer", extras = ["all"], specifier = ">=0.12.0" }, +] +provides-extras = ["interactive", "dev"] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "textual-dev" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "msgpack" }, + { name = "textual" }, + { name = "textual-serve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/fd/fd5ad9527b536c306a5a860a33a45e13983a59804b48b91ea52b42ba030a/textual_dev-1.8.0.tar.gz", hash = "sha256:7e56867b0341405a95e938cac0647e6d2763d38d0df08710469ad6b6a8db76df", size = 25026, upload-time = "2025-10-11T09:47:01.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/c6/1dc08ceee6d0bdf219c500cb2fbd605b681b63507c4ff27a6477f12f8245/textual_dev-1.8.0-py3-none-any.whl", hash = "sha256:227b6d24a485fbbc77e302aa21f4fdf3083beb57eb45cd95bae082c81cbeddeb", size = 27541, upload-time = "2025-10-11T09:47:00.531Z" }, +] + +[[package]] +name = "textual-serve" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-jinja2" }, + { name = "jinja2" }, + { name = "rich" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/7e/62fecc552853ec6a178cb1faa2d6f73b34d5512924770e7b08b58ff14148/textual_serve-1.1.3.tar.gz", hash = "sha256:f8f636ae2f5fd651b79d965473c3e9383d3521cdf896f9bc289709185da3f683", size = 448340, upload-time = "2025-11-01T16:22:36.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fe/108e7773349d500cf363328c3d0b7123e03feda51e310a3a5b136ac8ca71/textual_serve-1.1.3-py3-none-any.whl", hash = "sha256:207a472bc6604e725b1adab4ab8bf12f4c4dc25b04eea31e4d04731d8bf30f18", size = 447339, upload-time = "2025-11-01T16:22:35.209Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/d7/bc3d308a713ddc9648e5f9333deb808158d87f665b94f1156aac84081c54/virtualenv-21.7.6.tar.gz", hash = "sha256:ed47c4e3bbe5176d4f22e95e53917d4e52bc00c82d38d35085ca2955b52a83be", size = 5347084, upload-time = "2026-08-28T16:17:52.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2b/64e670b2d21774d591a808626ab5b9152cfb0c1902bc311a5f9b81a4fd3e/virtualenv-21.7.6-py3-none-any.whl", hash = "sha256:a15acc8f7d77ecb43ba80c4c2b254b9bfb8bdfe2163e079ede7333811bdab280", size = 5324758, upload-time = "2026-08-28T16:17:49.993Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]