commit 935abf86d4391d34b0f5865536e6f29883465b46 Author: Chris Farhood Date: Sat Jul 4 15:07:46 2026 -0400 Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch - Bump mcp[cli] 1.22 -> 1.28.1 (negotiates MCP protocol 2025-11-25, matching current Claude clients; the old 2025-06-18 server never got a tools/list on the connector surface). - Bake transport config into code: stateless_http + json_response for HTTP (single JSON body instead of a 34KB SSE stream, which the connector pipeline handles far more reliably). - Bake Authentik OAuth (AuthSettings + JWT TokenVerifier) into intervals_mcp_server.auth, configured from MCP_ISSUER/MCP_RESOURCE/MCP_JWKS_URI/MCP_CLIENT_ID — removes the runtime FastMCP.__init__ monkeypatch from the k8s deployment command. - Accept token audience with/without trailing slash (RFC 8707 clients use the slash-normalised resource metadata value). - Dockerfile CMD runs the module (transport via MCP_TRANSPORT); add .gitea CI to build+push the image to git.farh.net/farhoodlabs/intervalsicu-mcp. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.cursor/rules/api-patterns.mdc b/.cursor/rules/api-patterns.mdc new file mode 100644 index 0000000..7a5250f --- /dev/null +++ b/.cursor/rules/api-patterns.mdc @@ -0,0 +1,75 @@ +--- +description: +globs: +alwaysApply: true +--- +# API Communication Patterns + +## Intervals.icu API Integration + +This project communicates with the Intervals.icu API using consistent patterns defined in [src/intervals_mcp_server/server.py](mdc:src/intervals_mcp_server/server.py). + +## Core API Function + +All API communication goes through `make_intervals_request()`: + +```python +async def make_intervals_request( + url: str, + api_key: str | None = None, + params: dict[str, Any] | None = None +) -> dict[str, Any] | list[dict[str, Any]] +``` + +### Usage Pattern +- **URL Format**: Relative paths like `/athlete/{id}/activities` +- **Authentication**: HTTP Basic Auth with username "API_KEY" and password as the API key +- **Headers**: Includes User-Agent and Accept: application/json +- **Timeout**: 30 seconds for all requests + +## Error Handling Strategy + +### HTTP Status Code Mapping +The system provides user-friendly messages for common HTTP errors: +- `401 Unauthorized` - Invalid API key +- `403 Forbidden` - Permission denied +- `404 Not Found` - Resource doesn't exist +- `422 Unprocessable Entity` - Invalid parameters +- `429 Too Many Requests` - Rate limiting +- `500 Internal Server Error` - Server issues +- `503 Service Unavailable` - Maintenance/downtime + +### Error Response Format +All errors return a consistent structure: +```python +{ + "error": True, + "status_code": int, # HTTP status code + "message": str # User-friendly error message +} +``` + +## MCP Tool Implementation Pattern + +1. **Parameter Validation**: Check required parameters and provide defaults +2. **API Key Resolution**: Use provided key or fall back to global `API_KEY` +3. **Athlete ID Handling**: Support both numeric and i-prefixed formats +4. **Date Validation**: Parse and validate date strings +5. **API Request**: Call `make_intervals_request()` with appropriate parameters +6. **Error Checking**: Return formatted error messages for API failures +7. **Data Formatting**: Use utilities from [src/intervals_mcp_server/utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) + +## Environment Variables + +Required configuration (validated on startup): +- `API_KEY` - Cannot be empty +- `ATHLETE_ID` - Must match pattern `r"i?\d+"` (digits or i-prefixed digits) + +Optional configuration: +- `INTERVALS_API_BASE_URL` - Defaults to `https://intervals.icu/api/v1` + +## Shared HTTP Client + +Uses a single `httpx.AsyncClient` instance (`httpx_client`) managed by the FastMCP lifespan context manager to: +- Reuse connections for better performance +- Ensure proper cleanup when server stops diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc new file mode 100644 index 0000000..ef6718c --- /dev/null +++ b/.cursor/rules/development-workflow.mdc @@ -0,0 +1,54 @@ +--- +description: +globs: +alwaysApply: true +--- +# Development Workflow Guide + +## Environment Setup + +1. **Use uv for package management**: This project uses [uv](mdc:https:/github.com/astral-sh/uv) instead of pip + ```bash + uv venv --python 3.12 + source .venv/bin/activate + uv sync --all-extras + ``` + +2. **Environment Configuration**: Copy [.env.example](mdc:.env.example) to `.env` and configure: + - `API_KEY` - Your Intervals.icu API key + - `ATHLETE_ID` - Your athlete ID (digits or i-prefixed) + +## Running the Server + +- **Manual Testing**: `mcp run src/intervals_mcp_server/server.py` +- **Claude Desktop Integration**: Use `mcp install` command as documented in [README.md](mdc:README.md) + +## Code Quality Checks + +Before committing, ensure all three checks pass: + +1. **Linting**: `ruff .` - Uses default ruff rules, config in [pyproject.toml](mdc:pyproject.toml) +2. **Type Checking**: `mypy src tests` - Static type analysis +3. **Testing**: `pytest` - Unit tests in [tests/](mdc:tests) directory + +## Code Organization + +- **Main Logic**: All MCP tools are implemented in [src/intervals_mcp_server/server.py](mdc:src/intervals_mcp_server/server.py) +- **Utilities**: Helper functions in [src/intervals_mcp_server/utils/](mdc:src/intervals_mcp_server/utils) +- **API Communication**: `make_intervals_request()` function handles all Intervals.icu API calls +- **Error Handling**: Comprehensive HTTP error handling with user-friendly messages + +## Adding New MCP Tools + +1. Create async function decorated with `@mcp.tool()` +2. Add proper type hints and docstrings +3. Use `make_intervals_request()` for API calls +4. Add formatting utilities to [src/intervals_mcp_server/utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) if needed +5. Write unit tests in [tests/](mdc:tests) + +## Commit Guidelines + +- Use concise commit messages +- Title PRs as `[intervals-mcp-server] ` +- Ensure `ruff`, `mypy`, and `pytest` all pass +- Document manual testing steps in PR description diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 0000000..4ef4b8f --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,48 @@ +--- +description: +globs: +alwaysApply: true +--- +# Intervals.icu MCP Server Project Overview + +This is a Model Context Protocol (MCP) server for connecting Claude with the Intervals.icu API. The project enables Claude to retrieve and analyze athlete data including activities, events, workouts, and wellness metrics. + +## Project Structure + +- **Main Entry Point**: [src/intervals_mcp_server/server.py](mdc:src/intervals_mcp_server/server.py) - Contains the FastMCP server implementation with all MCP tools +- **Configuration**: [pyproject.toml](mdc:pyproject.toml) - Project configuration, dependencies, and build settings +- **Environment Setup**: [.env.example](mdc:.env.example) - Template for environment variables (API_KEY, ATHLETE_ID) +- **Documentation**: [README.md](mdc:README.md) - Comprehensive setup and usage guide +- **Developer Guide**: [AGENTS.md](mdc:AGENTS.md) - Contributor and development instructions + +## Core Components + +### MCP Tools (in server.py) +- `get_activities` - Retrieve athlete activities with filtering options +- `get_activity_details` - Get detailed information for specific activities +- `get_activity_intervals` - Get detailed interval data for activities +- `get_events` - Retrieve upcoming events (workouts, races, etc.) +- `get_event_by_id` - Get detailed information for specific events +- `get_wellness_data` - Fetch wellness metrics and data + +### Utilities +- **Formatting**: [src/intervals_mcp_server/utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) - Data formatting utilities for MCP responses + +### Testing +- **Tests Directory**: [tests/](mdc:tests) - Unit tests for server functionality and utilities +- **Sample Data**: [tests/sample_data.py](mdc:tests/sample_data.py) - Test data for development + +## Key Technologies +- **Python 3.12+** - Required runtime version +- **FastMCP** - MCP server framework +- **httpx** - Async HTTP client for API calls +- **uv** - Package manager and virtual environment tool +- **pytest** - Testing framework +- **ruff** - Linting and code formatting +- **mypy** - Static type checking + +## Environment Variables +- `API_KEY` - Intervals.icu API key (required) +- `ATHLETE_ID` - Target athlete ID (required) +- `INTERVALS_API_BASE_URL` - API base URL (optional, defaults to intervals.icu) +- `LOG_LEVEL` - Logging level (optional, defaults to INFO) diff --git a/.cursor/rules/python-best-practices.mdc b/.cursor/rules/python-best-practices.mdc new file mode 100644 index 0000000..e69bc8f --- /dev/null +++ b/.cursor/rules/python-best-practices.mdc @@ -0,0 +1,91 @@ +--- +description: +globs: +alwaysApply: true +--- +# Python Best Practices for Intervals MCP Server + +## Code Style and Readability + +- Follow **PEP 8** for formatting, indentation, and naming conventions +- Write readable, maintainable code: + - Use descriptive names for variables, functions, and classes + - Keep functions short and single-purpose (see MCP tools in [server.py](mdc:src/intervals_mcp_server/server.py)) + - Keep indentation and spacing consistent +- Embrace Pythonic idioms: + - Use list/dict comprehensions and generators where appropriate + - Prefer built-in functions and stdlib modules + - Use context managers (like the `lifespan` manager for httpx client) +- Avoid global variables except for validated constants (API_KEY, ATHLETE_ID) +- Use lazy **`%`** formatting in logging calls: `logger.debug("val=%s", val)` +- Represent datetimes as timezone-aware UTC objects when possible + +## Type Annotations and Documentation + +- Add **type hints** to all function signatures (see examples in [server.py](mdc:src/intervals_mcp_server/server.py)) +- Use built-in collection types (`list`, `dict`, `set`, `tuple`) instead of `typing.List`, etc. +- Provide clear **docstrings** for: + - All MCP tool functions (required by FastMCP) + - Public utility functions in [utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) + - The main module docstring explaining the server's purpose +- Use inline comments only for non-obvious logic + +## Error Handling and Validation + +- Handle errors gracefully with explicit `try/except` blocks (see `make_intervals_request()`) +- Catch specific exceptions: `httpx.HTTPStatusError`, `httpx.RequestError`, etc. +- Return consistent error structures with user-friendly messages +- Validate inputs: + - Check API key and athlete ID on startup + - Validate date formats in MCP tools + - Use regex pattern `r"i?\d+"` for athlete ID validation + +## Async Programming Patterns + +- Use `async/await` consistently for all MCP tools and API calls +- Share a single `httpx.AsyncClient` instance across requests +- Properly close async resources using lifespan context manager +- Follow FastMCP's async patterns for tool implementations + +## Testing Standards + +- Write unit tests for all MCP tools and utilities +- Use **pytest** with `pytest-asyncio` for async function testing +- Use **pytest-mock** (`MockerFixture`) for mocking HTTP requests +- Test both success and error paths +- Mock external API calls to avoid dependencies in tests + +## Development Environment + +- Target **Python 3.12+** as specified in [pyproject.toml](mdc:pyproject.toml) +- Use **uv** for package management: `uv sync --all-extras` +- Manage dependencies in `pyproject.toml` with lock file (`uv.lock`) +- Always use virtual environments (`.venv/`) +- Run quality checks before commits: + - `ruff .` for linting + - `mypy src tests` for type checking + - `pytest` for tests + +## Security Practices + +- Never hard-code secrets - use environment variables via `.env` file +- Load sensitive data (API_KEY, ATHLETE_ID) from environment +- Use HTTP Basic Auth for API authentication +- Validate all external inputs before processing +- Follow least-privilege principles for API access + +## Project-Specific Patterns + +- All API communication through `make_intervals_request()` function +- Consistent error response format: `{"error": True, "status_code": int, "message": str}` +- Use formatting utilities from [utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) +- Follow MCP tool naming conventions: `get_*` for retrieval operations +- Support both numeric and i-prefixed athlete IDs + +## Code Organization + +- Keep all MCP tools in [server.py](mdc:src/intervals_mcp_server/server.py) +- Place formatting utilities in [utils/formatting.py](mdc:src/intervals_mcp_server/utils/formatting.py) +- Organize tests by functionality in [tests/](mdc:tests) directory +- Use `__init__.py` files to mark Python packages +- Include `py.typed` for type checking support diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc new file mode 100644 index 0000000..80152aa --- /dev/null +++ b/.cursor/rules/testing-patterns.mdc @@ -0,0 +1,89 @@ +--- +description: +globs: +alwaysApply: true +--- +# Testing Patterns and Practices + +## Test Organization + +Tests are organized in the [tests/](mdc:tests) directory with the following structure: + +- **[tests/test_server.py](mdc:tests/test_server.py)** - Main MCP tool testing +- **[tests/test_formatting.py](mdc:tests/test_formatting.py)** - Utility function tests +- **[tests/test_make_intervals_request.py](mdc:tests/test_make_intervals_request.py)** - API communication tests +- **[tests/sample_data.py](mdc:tests/sample_data.py)** - Mock data for testing + +## Testing Framework Setup + +- **Framework**: pytest with async support (`pytest-asyncio`) +- **Mocking**: pytest-mock for HTTP request mocking +- **Configuration**: Test settings in [pyproject.toml](mdc:pyproject.toml) under `[tool.pytest.ini_options]` + +## Testing Patterns + +### Async Testing +All MCP tools are async functions, so tests use: +```python +@pytest.mark.asyncio +async def test_function_name(): + # Test async MCP tools +``` + +### Mock API Responses +HTTP requests are mocked using `pytest-mock`: +```python +def test_api_call(mocker): + mock_response = mocker.Mock() + mock_response.json.return_value = {"test": "data"} + mock_response.raise_for_status.return_value = None + + mocker.patch("httpx.AsyncClient.get", return_value=mock_response) +``` + +### Test Data Management +- **Sample Data**: [tests/sample_data.py](mdc:tests/sample_data.py) contains realistic mock data +- **Isolation**: Each test uses fresh mock data to avoid side effects +- **Coverage**: Tests cover both success and error scenarios + +## Running Tests + +```bash +# Run all tests +pytest + +# Run with verbose output +pytest -v + +# Run specific test file +pytest tests/test_server.py + +# Run with coverage (if installed) +pytest --cov=src/intervals_mcp_server +``` + +## Test Requirements + +Before committing code, ensure: +1. **All tests pass**: `pytest` returns exit code 0 +2. **No new linting errors**: `ruff .` passes +3. **Type checking passes**: `mypy src tests` succeeds + +## Writing New Tests + +When adding new MCP tools or utilities: + +1. **Create test cases** in appropriate test file +2. **Mock external dependencies** (HTTP requests, file I/O) +3. **Test both success and error paths** +4. **Use realistic test data** from [tests/sample_data.py](mdc:tests/sample_data.py) +5. **Follow async testing patterns** for MCP tools +6. **Verify error message formatting** for user-facing errors + +## Mock Strategy + +The project uses comprehensive mocking to: +- **Avoid real API calls** during testing +- **Test error handling** by simulating various HTTP error responses +- **Ensure deterministic results** with controlled test data +- **Speed up test execution** by eliminating network requests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98b6850 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Intervals.icu API Configuration + +# Base URL (Optional, defaults to https://intervals.icu/api/v1) +# INTERVALS_API_BASE_URL=https://intervals.icu/api/v1 + +# Required: Your Intervals.icu API Key +API_KEY=your_intervals_api_key_here + +# Required: Your Intervals.icu Athlete ID +ATHLETE_ID=your_athlete_id_here + +# The API_KEY and ATHLETE_ID will be used as defaults for all API calls +# when these parameters are not explicitly provided to the tool functions. diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..ccdb013 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,31 @@ +name: build-image + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + env: + IMAGE: git.farh.net/farhoodlabs/intervalsicu-mcp + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and push image + run: | + set -eu + echo "${{ secrets.GITEA_TOKEN }}" | docker login git.farh.net -u "${{ github.actor }}" --password-stdin + docker build \ + -t "${IMAGE}:latest" \ + -t "${IMAGE}:${GITHUB_SHA}" \ + . + docker push "${IMAGE}:latest" + docker push "${IMAGE}:${GITHUB_SHA}" + echo "pushed ${IMAGE}:latest and ${IMAGE}:${GITHUB_SHA}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27b8ebe --- /dev/null +++ b/.gitignore @@ -0,0 +1,185 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# VSCode +.vscode + +# TODO +TODO + +.gitconfig +.claude/ +.DS_Store +CLAUDE.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9fec0d5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +# Quick Start: +# +# pip install pre-commit +# pre-commit install && pre-commit install -t pre-push +# pre-commit run --all-files +# +# To Skip Checks: +# +# git commit --no-verify +# git push --no-verify +# test +# +# To update all hooks automatically: +# +# pre-commit autoupdate +fail_fast: false + +default_language_version: + python: python3.12 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: no-commit-to-branch # prevent direct commits to the `main` branch + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + + # ruff + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.11.11 + hooks: + # Run the linter. + - id: ruff-check + args: [ --fix ] + # Run the formatter. + - id: ruff-format + + # typos + - repo: https://github.com/crate-ci/typos + rev: v1.32.0 + hooks: + - id: typos + + # pytest + - repo: local + hooks: + - id: tests + name: run tests + entry: pytest -v tests + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9289abd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Contributor Guide + +This project is a Python 3.12 backend service built with FastMCP and httpx. All source code lives under `src/intervals_mcp_server` and tests live under `tests`. + +## Development Environment +- Use [uv](https://github.com/astral-sh/uv) to create and manage the virtual environment. + - `uv venv --python 3.12` + - `source .venv/bin/activate` +- Sync dependencies including dev extras with `uv sync --all-extras`. +- When editing or running the server manually use `mcp run src/intervals_mcp_server/server.py`. + +## Testing Instructions +- Run unit tests with `pytest` from the repository root. +- Ensure linting passes with `ruff .` (no configuration file means default rules). +- Run static type checks using `mypy src tests`. +- All three steps (`ruff`, `mypy`, and `pytest`) should succeed before committing. + +## PR Instructions +- Use concise commit messages. +- Title pull requests using the format `[intervals-mcp-server] `. +- Describe any manual testing steps performed and mention whether `pytest`, `ruff`, and `mypy` passed. + +There is currently no frontend code in this repository. If a frontend is added in the future (for example with React or another framework), document how to run and test it within this file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e2c2a70 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing to Intervals.icu MCP Server + +Thank you for taking the time to contribute! This project uses **Python 3.12** and manages its dependencies with [uv](https://github.com/astral-sh/uv). The following guide summarizes how to set up your environment and outlines the workflow we expect for pull requests. + +## Development environment + +1. Create a virtual environment and activate it: + ```bash + uv venv --python 3.12 + source .venv/bin/activate + ``` +2. Install all dependencies (including development extras): + ```bash + uv sync --all-extras + ``` +3. When working on or manually running the server, use: + ```bash + mcp run src/intervals_mcp_server/server.py + ``` + +## Dependency changes + +1. Edit `pyproject.toml`. +2. Run `uv lock` (or `uv sync`). +3. Commit **both** `pyproject.toml` and `uv.lock` in the same commit. + +If you add, remove, or relax a dependency but forget to update the lock file, CI will fail. Treat `uv.lock` as a first-class artifact: review it when it changes, but don’t fear committing it. + +## Code-only changes + +For changes that do not modify dependencies, keep the lock file untouched. Run your tests with: + +```bash +uv run --locked pytest +``` + +CI will also run `uv lock --check` to ensure `uv.lock` stays in sync. + +## Why keep the lock file? + +* **Reproducibility** – All collaborators and CI runners install identical hashes. +* **Security** – Hash pinning in `uv.lock` helps prevent supply-chain attacks. +* **Speed** – `uv` skips resolution when the lock matches, keeping installs lightning-fast. + +Automated dependency upgrades are encouraged. You can use Dependabot, Renovate, or a scheduled GitHub Action that runs `uv lock --upgrade && git push` to keep the file fresh and generate tidy PRs. + +## Testing + +Before opening a pull request, ensure all checks pass locally: + +```bash +ruff check . +mypy src tests +uv run --locked pytest +``` + +## Pull request guidelines + +* Use concise commit messages. +* Title your pull request using the format `[intervals-mcp-server] `. +* Describe any manual testing you performed and confirm whether `ruff`, `mypy`, and `pytest` passed. + +We appreciate your contributions and your attention to these guidelines. Happy coding! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b6c64e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install build dependencies and Python build backend +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python build tool +RUN pip install --no-cache-dir hatchling + +# Copy project files +COPY pyproject.toml pyproject.toml +COPY src src +COPY README.md README.md +COPY .env.example .env.example + +# Install the package and runtime dependencies +RUN pip install --no-cache-dir . + +# Run the MCP server. Transport is selected via MCP_TRANSPORT (default stdio); +# the deployment sets MCP_TRANSPORT=http for streamable-HTTP. Auth + stateless +# JSON transport are configured in code (see mcp_instance.py), no monkeypatch. +CMD ["python", "-m", "intervals_mcp_server.server"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..49ddf14 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include README.md +include .env.example +include server.py +include utils/*.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..8da865a --- /dev/null +++ b/README.md @@ -0,0 +1,379 @@ +# Intervals.icu MCP Server + +Model Context Protocol (MCP) server for connecting Claude and ChatGPT with the Intervals.icu API. It provides tools for authentication and data retrieval for activities, events, wellness data, power curves, and custom items. + +If you find the Model Context Protocol (MCP) server useful, please consider supporting its continued development with a donation. + +## Requirements + +- Python 3.12 or higher +- [Model Context Protocol (MCP) Python SDK](https://github.com/modelcontextprotocol/python-sdk) +- httpx +- python-dotenv + +## Setup + +### 1. Install uv (recommended) + +**macOS/Linux:** +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows (PowerShell):** +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +After installation, find the full path to `uv` — you'll need it later when configuring Claude Desktop: + +```powershell +where.exe uv +# Example output: C:\Users\\.local\bin\uv.exe +``` + +### 2. Clone this repository + +```bash +git clone https://github.com/mvilanova/intervals-mcp-server.git +cd intervals-mcp-server +``` + +### 3. Create and activate a virtual environment + +```bash +# Create virtual environment with Python 3.12 +uv venv --python 3.12 + +# Activate virtual environment +# On macOS/Linux: +source .venv/bin/activate +# On Windows: +.venv\Scripts\activate +``` + +### 4. Sync project dependencies + +```bash +uv sync +``` + +### 5. Set up environment variables + +Make a copy of `.env.example` and name it `.env` by running the following command: + +**macOS/Linux:** +```bash +cp .env.example .env +``` + +**Windows (PowerShell):** +```powershell +Copy-Item .env.example .env +``` + +Then edit the `.env` file and set your Intervals.icu athlete id and API key: + +``` +API_KEY=your_intervals_api_key_here +ATHLETE_ID=your_athlete_id_here +``` + +#### Getting your Intervals.icu API Key + +1. Log in to your Intervals.icu account +2. Go to Settings > API +3. Generate a new API key + +#### Finding your Athlete ID + +Your athlete ID is typically visible in the URL when you're logged into Intervals.icu. It looks like: + +- `https://intervals.icu/athlete/i12345/...` where `i12345` is your athlete ID + +## Updating + +This project is actively developed, with new features and fixes added regularly. To stay up to date, follow these steps: + +### 1. Pull the latest changes from `main` + +> ⚠️ Make sure you don't have uncommitted changes before running this command. + +**macOS/Linux:** +```bash +git checkout main && git pull +``` + +**Windows (PowerShell):** +```powershell +git checkout main; git pull +``` + +### 2. Update Python dependencies + +Activate your virtual environment and sync dependencies: + +**macOS/Linux:** +```bash +source .venv/bin/activate +uv sync +``` + +**Windows (PowerShell):** +```powershell +.venv\Scripts\activate +uv sync +``` + +### Troubleshooting + +If Claude Desktop fails due to configuration changes, follow these steps: + +1. Delete the existing `Intervals.icu` entry in `claude_desktop_config.json`. +2. Reconfigure Claude Desktop from the `intervals-mcp-server` directory. + +**macOS/Linux:** +```bash +mcp install src/intervals_mcp_server/server.py --name "Intervals.icu" --with-editable . --env-file .env +``` + +**Windows:** Re-add the entry manually as described in the [Windows configuration section](#windows). + +#### Common errors + +**`spawn uv ENOENT`** — Claude Desktop cannot find the `uv` executable. Use the full path to `uv` in the `command` field. Run `which uv` (macOS/Linux) or `where.exe uv` (Windows) to get it. + +**`spawn /Users/... ENOENT` on Windows** — The config file contains a macOS/Linux-style path. Replace it with the correct Windows path using backslashes as described in the [Windows configuration section](#windows) below. + +**Windows Store install: config changes not taking effect** — You may be editing the wrong config file. Claude Desktop installed from the Microsoft Store reads from `AppData\Local\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json`, not `AppData\Roaming\Claude\`. + +## Usage with Claude + +### 1. Configure Claude Desktop + +To use this server with Claude Desktop, you need to add it to your Claude Desktop configuration. + +#### macOS/Linux + +1. Run the following from the `intervals-mcp-server` directory to configure Claude Desktop: + +```bash +mcp install src/intervals_mcp_server/server.py --name "Intervals.icu" --with-editable . --env-file .env +``` + +2. If you open your Claude Desktop App configuration file `claude_desktop_config.json`, it should look like this: + +```json +{ + "mcpServers": { + "Intervals.icu": { + "command": "/Users//.local/bin/uv", + "args": [ + "run", + "--with", + "mcp[cli]", + "--with-editable", + "/path/to/intervals-mcp-server", + "mcp", + "run", + "/path/to/intervals-mcp-server/src/intervals_mcp_server/server.py" + ], + "env": { + "INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1", + "ATHLETE_ID": "", + "API_KEY": "", + "LOG_LEVEL": "INFO" + } + } + } +} +``` + +Where `/path/to/` is the path to the `intervals-mcp-server` code folder in your system. + +#### Windows + +The `mcp install` command may fail on Windows due to environment or permission issues. Instead, configure Claude Desktop manually: + +1. Find the Claude Desktop config file. If Claude Desktop was installed from the **Microsoft Store**, the config is located at: + + ``` + C:\Users\\AppData\Local\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json + ``` + + If installed via the standard installer, it may be at: + + ``` + C:\Users\\AppData\Roaming\Claude\claude_desktop_config.json + ``` + + If the file or folder does not exist, create it. + +2. Add the following entry to `claude_desktop_config.json`, replacing the placeholders with your actual values: + +```json +{ + "mcpServers": { + "Intervals.icu": { + "command": "C:\\Users\\\\.local\\bin\\uv.exe", + "args": [ + "run", + "--with", + "mcp[cli]", + "--with-editable", + "C:\\path\\to\\intervals-mcp-server", + "mcp", + "run", + "C:\\path\\to\\intervals-mcp-server\\src\\intervals_mcp_server\\server.py" + ], + "env": { + "INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1", + "ATHLETE_ID": "", + "API_KEY": "", + "LOG_LEVEL": "INFO" + } + } + } +} +``` + +- Use double backslashes (`\\`) for all Windows paths in JSON. +- To find the full path to `uv.exe`, run `where.exe uv` in PowerShell. +- To find the full path to the cloned repository, run `pwd` from inside the `intervals-mcp-server` folder. + +> **Note for Windows Store installs:** Claude Desktop installed from the Microsoft Store sandboxes its config under `AppData\Local\Packages\...`. Editing `AppData\Roaming\Claude\claude_desktop_config.json` will have no effect — make sure you edit the correct file. + +3. Restart Claude Desktop. + +### 2. Use the MCP server with Claude + +Once the server is running and Claude Desktop is configured, you can use the following tools to ask questions about your past and future activities, events, and wellness data. + +- `get_activities`: Retrieve a list of activities +- `get_activity_details`: Get detailed information for a specific activity +- `get_activity_intervals`: Get detailed interval data for a specific activity +- `get_activity_streams`: Get raw data streams (power, heart rate, etc.) for a specific activity +- `get_athlete_power_curves`: Get best power output curves for selected durations and time periods +- `get_wellness_data`: Fetch wellness data +- `get_events`: Retrieve upcoming events (workouts, races, etc.) +- `get_event_by_id`: Get detailed information for a specific event +- `add_or_update_event`: Create or update an event (workout, race, note, etc.) +- `delete_event`: Delete a specific event +- `delete_events_by_date_range`: Delete events within a date range +- `get_custom_items`: Get custom items (charts, custom fields, zones, etc.) for an athlete +- `get_custom_item_by_id`: Get detailed information for a specific custom item +- `create_custom_item`: Create a new custom item for an athlete +- `update_custom_item`: Update an existing custom item +- `delete_custom_item`: Delete a custom item + +## Usage with ChatGPT + +ChatGPT’s beta MCP connectors can also talk to this server over the SSE transport. + +1. Start the server in SSE mode so it exposes the `/sse` and `/messages/` endpoints: + + ```bash + export FASTMCP_HOST=127.0.0.1 FASTMCP_PORT=8765 MCP_TRANSPORT=sse FASTMCP_LOG_LEVEL=INFO + python src/intervals_mcp_server/server.py + ``` + + The startup log prints the full URLs (for example `http://127.0.0.1:8765/sse`). ChatGPT needs that public URL, so forward the port with a tool such as `ngrok http 8765` if you are not exposing the server directly. + +2. In ChatGPT, open **Settings → Features → Custom MCP Connectors** and click **Add**. Fill in: + + - **Name**: `Intervals.icu` + - **MCP Server URL**: `https:///sse` + - **Authentication**: leave as _No authentication_ unless you have protected your tunnel. + + You can reuse the same `ngrok http 8765` tunnel URL here; just ensure it forwards to the host/port you exported above. + +3. Save the connector and open a new chat. ChatGPT will keep the SSE connection open and POST follow-up requests to the `/messages/` endpoint announced by the server. If you restart the MCP server or tunnel, rerun the SSE command and update the connector URL if it changes. + +## Development and testing + +Install development dependencies and run the test suite with: + +```bash +uv sync --all-extras +pytest -v tests +``` + +### Running the server locally + +To start the server manually (useful when developing or testing), run: + +```bash +mcp run src/intervals_mcp_server/server.py +``` + +#### Enabling debug logging + +To capture server logs for debugging, wrap the command in a shell and redirect stderr to a file. + +**macOS/Linux** — modify your `claude_desktop_config.json` like this: + +```json +{ + "mcpServers": { + "Intervals.icu": { + "command": "/bin/bash", + "args": [ + "-c", + "/Users//.local/bin/uv run --with 'mcp[cli]' --with-editable /path/to/intervals-mcp-server mcp run /path/to/intervals-mcp-server/src/intervals_mcp_server/server.py 2>> /path/to/intervals-mcp-server/mcp-server.log" + ], + "env": { + "INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1", + "ATHLETE_ID": "", + "API_KEY": "", + "LOG_LEVEL": "INFO" + } + } + } +} +``` + +Then tail the log file to see output in real-time: + +```bash +tail -f /path/to/intervals-mcp-server/mcp-server.log +``` + +**Windows** — modify your `claude_desktop_config.json` like this: + +```json +{ + "mcpServers": { + "Intervals.icu": { + "command": "powershell", + "args": [ + "-Command", + "C:\\Users\\\\.local\\bin\\uv.exe run --with 'mcp[cli]' --with-editable C:\\path\\to\\intervals-mcp-server mcp run C:\\path\\to\\intervals-mcp-server\\src\\intervals_mcp_server\\server.py 2>> C:\\path\\to\\intervals-mcp-server\\mcp-server.log" + ], + "env": { + "INTERVALS_API_BASE_URL": "https://intervals.icu/api/v1", + "ATHLETE_ID": "", + "API_KEY": "", + "LOG_LEVEL": "INFO" + } + } + } +} +``` + +Then monitor the log file in real-time using PowerShell: + +```powershell +Get-Content C:\path\to\intervals-mcp-server\mcp-server.log -Wait +``` + +## License + +The GNU General Public License v3.0 + +## Featured + +### Glama.ai + + + Intervals.icu Server MCP server + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bde638c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,123 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "intervalsicu-mcp" +version = "0.1.0" +description = "A Model Context Protocol server for Intervals.icu (FastMCP, native OAuth)" +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.12" +license = { text = "GPL-3.0-only" } +authors = [{ name = "Marc Vilanova", email = "barker-riddle.8z@icloud.com" }] +dependencies = [ + "mcp[cli]>=1.28.1", + "httpx>=0.25.0", + "python-dotenv>=1.0.0", + "pyjwt[crypto]>=2.8.0", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] +keywords = ["intervals", "cycling", "running", "mcp", "ai"] + +[project.urls] +"Homepage" = "https://git.farh.net/farhoodlabs/intervalsicu-mcp" +"Bug Tracker" = "https://git.farh.net/farhoodlabs/intervalsicu-mcp/issues" +"Upstream" = "https://github.com/mvilanova/intervals-mcp-server" + +[project.optional-dependencies] +dev = ["pytest>=8.3.5", "mypy>=1.0.0", "ruff>=0.1.0", "pytest-asyncio>=0.21", "pre-commit", "hatch", "pytest-mock==3.12.0"] + +[tool.hatch.build] +include = ["server.py", "utils/*.py", "README.md", ".env.example"] + +[tool.hatch.build.targets.wheel] +packages = ["src/intervals_mcp_server"] + +[tool.hatch.envs.default.scripts] +test = "pytest -q" + + + +[tool.ruff] +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", +] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +# Select specific errors and warnings +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + # "I", # isort + "C", # flake8-comprehensions + "B", # flake8-bugbear +] + +# Ignore specific errors and warnings +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "C901", # complexity + "G004", # Ignore f-string in logging +] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "B", "C", "D", "E", "F"] + +# No unfixable rules. +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +isort.known-third-party = ["starlette"] + +# Unlike Flake8, default to a complexity level of 10. +mccabe.max-complexity = 10 + +[tool.pylint.design] +max-args = 8 +max-positional-arguments = 8 + +[tool.typos] +default.check-filename = true +default.check-file = true +default.unicode = true +default.locale = "en-us" + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] +python_files = "test_*.py" +asyncio_default_fixture_loop_scope = "function" + +[tool.uv] +index-url = "https://pypi.org/simple" diff --git a/src/intervals_mcp_server/__init__.py b/src/intervals_mcp_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/intervals_mcp_server/api/__init__.py b/src/intervals_mcp_server/api/__init__.py new file mode 100644 index 0000000..c05c817 --- /dev/null +++ b/src/intervals_mcp_server/api/__init__.py @@ -0,0 +1,5 @@ +""" +API client module for Intervals.icu MCP Server. + +This module contains the HTTP client and API request handling logic. +""" diff --git a/src/intervals_mcp_server/api/client.py b/src/intervals_mcp_server/api/client.py new file mode 100644 index 0000000..685d973 --- /dev/null +++ b/src/intervals_mcp_server/api/client.py @@ -0,0 +1,242 @@ +""" +API client for Intervals.icu MCP Server. + +This module handles all HTTP communication with the Intervals.icu API, +including request management, error handling, and client lifecycle. +""" + +from json import JSONDecodeError +import json +import logging +import sys +from contextlib import asynccontextmanager +from http import HTTPStatus +from typing import Any + +import httpx # pylint: disable=import-error +from mcp.server.fastmcp import FastMCP # pylint: disable=import-error + +from intervals_mcp_server.config import get_config + +logger = logging.getLogger("intervals_icu_mcp_server") + +# Create a single AsyncClient instance for all requests (lazily initialized) +# This can be monkeypatched via server.httpx_client for testing +httpx_client: httpx.AsyncClient | None = None # pylint: disable=invalid-name + + +async def _get_httpx_client() -> httpx.AsyncClient: + """ + Lazily create or reuse the shared httpx AsyncClient. + + The client may be closed by downstream transports between tool invocations, + so we recreate it when necessary. + + This function checks server.httpx_client first (if available) to support + test monkeypatching via server.httpx_client. + """ + global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here + + # Check for monkeypatched client in server module first (for test compatibility) + # This allows tests to monkeypatch server.httpx_client and have it work + try: + server_module = sys.modules.get("intervals_mcp_server.server") + if server_module and hasattr(server_module, "httpx_client"): + server_client = server_module.httpx_client + if server_client is not None and not server_client.is_closed: + return server_client + except (AttributeError, ImportError): + pass + + # Use this module's httpx_client + if httpx_client is None or httpx_client.is_closed: + httpx_client = httpx.AsyncClient() + return httpx_client + + +@asynccontextmanager +async def setup_api_client(_app: FastMCP): + """ + Context manager to ensure the shared httpx client is closed when the server stops. + + Args: + _app (FastMCP): The MCP server application instance. + """ + try: + yield + finally: + # Close the module-level httpx_client + if httpx_client and not httpx_client.is_closed: + await httpx_client.aclose() + + # Also close server.httpx_client if it exists (for test compatibility) + # This ensures monkeypatched clients in tests are properly closed + try: + server_module = sys.modules.get("intervals_mcp_server.server") + if server_module and hasattr(server_module, "httpx_client"): + server_client = getattr(server_module, "httpx_client", None) + if server_client is not None and not server_client.is_closed: + await server_client.aclose() + except (AttributeError, ImportError): + pass + + +def _get_error_message(error_code: int, error_text: str) -> str: + """Return a user-friendly error message for a given HTTP status code.""" + error_messages = { + HTTPStatus.UNAUTHORIZED: f"{HTTPStatus.UNAUTHORIZED.value} {HTTPStatus.UNAUTHORIZED.phrase}: Please check your API key.", + HTTPStatus.FORBIDDEN: f"{HTTPStatus.FORBIDDEN.value} {HTTPStatus.FORBIDDEN.phrase}: You may not have permission to access this resource.", + HTTPStatus.NOT_FOUND: f"{HTTPStatus.NOT_FOUND.value} {HTTPStatus.NOT_FOUND.phrase}: The requested endpoint or ID doesn't exist.", + HTTPStatus.UNPROCESSABLE_ENTITY: f"{HTTPStatus.UNPROCESSABLE_ENTITY.value} {HTTPStatus.UNPROCESSABLE_ENTITY.phrase}: The server couldn't process the request (invalid parameters or unsupported operation).", + HTTPStatus.TOO_MANY_REQUESTS: f"{HTTPStatus.TOO_MANY_REQUESTS.value} {HTTPStatus.TOO_MANY_REQUESTS.phrase}: Too many requests in a short time period.", + HTTPStatus.INTERNAL_SERVER_ERROR: f"{HTTPStatus.INTERNAL_SERVER_ERROR.value} {HTTPStatus.INTERNAL_SERVER_ERROR.phrase}: The Intervals.icu server encountered an internal error.", + HTTPStatus.SERVICE_UNAVAILABLE: f"{HTTPStatus.SERVICE_UNAVAILABLE.value} {HTTPStatus.SERVICE_UNAVAILABLE.phrase}: The Intervals.icu server might be down or undergoing maintenance.", + } + try: + status = HTTPStatus(error_code) + return error_messages.get(status, error_text) + except ValueError: + return error_text + + +def _prepare_request_config( + url: str, + api_key: str | None, + method: str, +) -> tuple[str, httpx.BasicAuth, dict[str, str], str | None]: + """Prepare request configuration including headers, auth, and URL. + + Returns: + Tuple of (full_url, auth, headers, error_message). + error_message is None if configuration is valid. + """ + config = get_config() + headers = {"User-Agent": config.user_agent, "Accept": "application/json"} + + if method in ["POST", "PUT"]: + headers["Content-Type"] = "application/json" + + # Use provided api_key or fall back to global API_KEY + key_to_use = api_key if api_key is not None else config.api_key + if not key_to_use: + logger.error("No API key provided for request to: %s", url) + return ( + "", + httpx.BasicAuth("", ""), + {}, + "API key is required. Set API_KEY env var or pass api_key", + ) + + auth = httpx.BasicAuth("API_KEY", key_to_use) + full_url = f"{config.intervals_api_base_url}{url}" + return full_url, auth, headers, None + + +def _parse_response( + response: httpx.Response, full_url: str +) -> dict[str, Any] | list[dict[str, Any]]: + """Parse HTTP response and return JSON data or error dict. + + Returns: + Parsed JSON response or error dict. + """ + try: + response_data = response.json() if response.content else {} + except JSONDecodeError: + logger.error("Invalid JSON in response from: %s", full_url) + return {"error": True, "message": "Invalid JSON in response"} + response.raise_for_status() + return response_data + + +async def make_intervals_request( + url: str, + api_key: str | None = None, + params: dict[str, Any] | None = None, + method: str = "GET", + data: dict[str, Any] | None = None, +) -> dict[str, Any] | list[dict[str, Any]]: + """ + Make a request to the Intervals.icu API with proper error handling. + + Args: + url (str): The API endpoint path (e.g., '/athlete/{id}/activities'). + api_key (str | None): Optional API key to use for authentication. Defaults to the global API_KEY. + params (dict[str, Any] | None): Optional query parameters for the request. + method (str): HTTP method to use (GET, POST, etc.). Defaults to GET. + data (dict[str, Any] | None): Optional data to send in the request body. + + Returns: + dict[str, Any] | list[dict[str, Any]]: The parsed JSON response from the API, or an error dict. + """ + # Prepare request configuration + full_url, auth, headers, error_msg = _prepare_request_config(url, api_key, method) + if error_msg: + return {"error": True, "message": error_msg} + + async def _send_request(client: httpx.AsyncClient) -> httpx.Response: + if method in {"POST", "PUT"} and data is not None: + body = json.dumps(data) + logger.debug("Request %s %s body: %s", method, full_url, body) + return await client.request( + method=method, + url=full_url, + headers=headers, + params=params, + auth=auth, + timeout=30.0, + content=body, + ) + return await client.request( + method=method, + url=full_url, + headers=headers, + params=params, + auth=auth, + timeout=30.0, + ) + + try: + client = await _get_httpx_client() + + try: + response = await _send_request(client) + except RuntimeError as runtime_error: + # httpx closes the client when the underlying connection is severed; + # recreate the shared client lazily and retry once. + if "client has been closed" not in str(runtime_error).lower(): + raise + logger.warning("HTTPX client was closed; creating a new instance for retries.") + global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here + httpx_client = None + client = await _get_httpx_client() + response = await _send_request(client) + + return _parse_response(response, full_url) + except httpx.HTTPStatusError as e: + return _handle_http_status_error(e) + except httpx.RequestError as e: + logger.error("Request error: %s", str(e)) + return {"error": True, "message": f"Request error: {str(e)}"} + except httpx.HTTPError as e: + logger.error("HTTP client error: %s", str(e)) + return {"error": True, "message": f"HTTP client error: {str(e)}"} + + +def _handle_http_status_error(e: httpx.HTTPStatusError) -> dict[str, Any]: + """Handle HTTP status errors and return formatted error dict. + + Args: + e: The HTTPStatusError exception. + + Returns: + Error dictionary with status code and message. + """ + error_code = e.response.status_code + error_text = e.response.text + logger.error("HTTP error: %s - %s", error_code, error_text) + return { + "error": True, + "status_code": error_code, + "message": _get_error_message(error_code, error_text), + } diff --git a/src/intervals_mcp_server/auth.py b/src/intervals_mcp_server/auth.py new file mode 100644 index 0000000..2ebcd1f --- /dev/null +++ b/src/intervals_mcp_server/auth.py @@ -0,0 +1,101 @@ +""" +Native OAuth token verification for the Intervals.icu MCP Server. + +This replaces the previous runtime monkeypatch: authentication is configured +here, in code, and enabled automatically when the OAuth environment variables +(``MCP_ISSUER`` / ``MCP_RESOURCE`` / ``MCP_JWKS_URI``) are present — i.e. for +the HTTP transport running behind an OAuth authorization server (Authentik). + +When those variables are absent (e.g. stdio / local development / tests) auth +is disabled and the server runs unauthenticated. +""" + +from __future__ import annotations + +import logging +import os + +logger = logging.getLogger("intervals_icu_mcp_server") + + +class AuthentikTokenVerifier: + """Verify RS256 Bearer JWTs against a JWKS endpoint (RFC 9068 style).""" + + def __init__(self, jwks_uri: str, issuer: str, audience: list[str]): + import jwt # PyJWT + + self._jwks = jwt.PyJWKClient(jwks_uri) + self._issuer = issuer + self._audience = audience + + async def verify_token(self, token: str): + """Return an AccessToken if the JWT is valid, else None (unauthenticated).""" + import jwt + from mcp.server.auth.provider import AccessToken + + try: + key = self._jwks.get_signing_key_from_jwt(token).key + claims = jwt.decode( + token, + key, + algorithms=["RS256"], + issuer=self._issuer, + audience=self._audience, + options={"require": ["exp", "iat", "iss", "aud"]}, + ) + except Exception as exc: # noqa: BLE001 - any failure means unauthenticated + logger.debug("Token verification failed: %s", exc) + return None + + aud = claims.get("aud") + resource = aud[0] if isinstance(aud, list) else aud + return AccessToken( + token=token, + client_id=claims.get("azp") or resource, + scopes=(claims.get("scope") or "").split(), + expires_at=claims.get("exp"), + resource=resource, + subject=claims.get("sub"), + claims=claims, + ) + + +def _audience_variants(resource: str, client_id: str | None) -> list[str]: + """Accepted token audiences. + + RFC 8707 clients use the ``resource`` value advertised in the protected + resource metadata, which pydantic's ``AnyHttpUrl`` normalises *with* a + trailing slash. The raw env var is typically supplied *without* one, so we + accept both forms (plus the OAuth client_id) to avoid audience mismatches. + """ + base = resource.rstrip("/") + values = [base, base + "/"] + if client_id: + values.append(client_id) + # de-duplicate while preserving order + seen: dict[str, None] = {} + for value in values: + seen.setdefault(value, None) + return list(seen.keys()) + + +def build_auth(): + """Return ``(AuthSettings, TokenVerifier)`` when OAuth is configured, else ``(None, None)``.""" + issuer = os.getenv("MCP_ISSUER") + resource = os.getenv("MCP_RESOURCE") + jwks_uri = os.getenv("MCP_JWKS_URI") + client_id = os.getenv("MCP_CLIENT_ID") + + if not (issuer and resource and jwks_uri): + return None, None + + from mcp.server.auth.settings import AuthSettings + from pydantic import AnyHttpUrl + + settings = AuthSettings( + issuer_url=AnyHttpUrl(issuer), + resource_server_url=AnyHttpUrl(resource), + ) + verifier = AuthentikTokenVerifier(jwks_uri, issuer, _audience_variants(resource, client_id)) + logger.info("Native OAuth enabled (issuer=%s, resource=%s)", issuer, resource) + return settings, verifier diff --git a/src/intervals_mcp_server/config.py b/src/intervals_mcp_server/config.py new file mode 100644 index 0000000..fde7b15 --- /dev/null +++ b/src/intervals_mcp_server/config.py @@ -0,0 +1,72 @@ +""" +Configuration management for Intervals.icu MCP Server. + +This module handles loading and validation of configuration from environment variables. +""" + +import os +from dataclasses import dataclass + +from intervals_mcp_server.utils.validation import validate_athlete_id + +# Try to load environment variables from .env file if it exists +try: + from dotenv import load_dotenv + + _ = load_dotenv() +except ImportError: + # python-dotenv not installed, proceed without it + pass + + +@dataclass +class Config: + """Configuration settings for the Intervals.icu MCP Server.""" + + api_key: str + athlete_id: str + intervals_api_base_url: str + user_agent: str + + +_config_instance: Config | None = None # pylint: disable=invalid-name + + +def load_config() -> Config: + """ + Load configuration from environment variables. + + Returns: + Config: Configuration instance with loaded values. + + Raises: + ValueError: If athlete_id is invalid (when non-empty). + """ + api_key = os.getenv("API_KEY", "") + athlete_id = os.getenv("ATHLETE_ID", "") + intervals_api_base_url = os.getenv("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1") + user_agent = "intervalsicu-mcp-server/1.0" + + # Validate athlete_id if provided (empty string is allowed) + if athlete_id: + validate_athlete_id(athlete_id) + + return Config( + api_key=api_key, + athlete_id=athlete_id, + intervals_api_base_url=intervals_api_base_url, + user_agent=user_agent, + ) + + +def get_config() -> Config: + """ + Get the configuration instance (singleton pattern). + + Returns: + Config: The configuration instance. + """ + global _config_instance # pylint: disable=global-statement # noqa: PLW0603 - singleton pattern + if _config_instance is None: + _config_instance = load_config() + return _config_instance diff --git a/src/intervals_mcp_server/mcp_instance.py b/src/intervals_mcp_server/mcp_instance.py new file mode 100644 index 0000000..4081774 --- /dev/null +++ b/src/intervals_mcp_server/mcp_instance.py @@ -0,0 +1,43 @@ +""" +Shared MCP instance module. + +Provides a shared FastMCP instance imported by both the server module and the +tool modules (avoiding cyclic imports). Transport and authentication are +configured here from the environment — there is no runtime monkeypatching: + +- HTTP transport (``MCP_TRANSPORT=http``/``streamable-http``) is served + statelessly with plain JSON responses (robust behind proxies / MCP + connectors, which handle a single JSON body far better than a large SSE + stream). +- Native OAuth (Authentik) is enabled when ``MCP_ISSUER`` / ``MCP_RESOURCE`` / + ``MCP_JWKS_URI`` are set. See :mod:`intervals_mcp_server.auth`. +""" + +from __future__ import annotations + +import os +from typing import Any + +from mcp.server.fastmcp import FastMCP # pylint: disable=import-error + +from intervals_mcp_server.api.client import setup_api_client +from intervals_mcp_server.auth import build_auth + +_kwargs: dict[str, Any] = {"lifespan": setup_api_client} + +# HTTP transport tuning: stateless + single JSON response body. +if os.getenv("MCP_TRANSPORT", "stdio").lower() in ("http", "streamable-http"): + _kwargs["stateless_http"] = True + _kwargs["json_response"] = True + if os.getenv("FASTMCP_HOST"): + _kwargs["host"] = os.environ["FASTMCP_HOST"] + if os.getenv("FASTMCP_PORT"): + _kwargs["port"] = int(os.environ["FASTMCP_PORT"]) + +# Native OAuth (Authentik) when configured via environment. +_auth_settings, _token_verifier = build_auth() +if _auth_settings is not None and _token_verifier is not None: + _kwargs["auth"] = _auth_settings + _kwargs["token_verifier"] = _token_verifier + +mcp: FastMCP = FastMCP("intervals-icu", **_kwargs) # pylint: disable=invalid-name diff --git a/src/intervals_mcp_server/py.typed b/src/intervals_mcp_server/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/intervals_mcp_server/server.py b/src/intervals_mcp_server/server.py new file mode 100644 index 0000000..074196c --- /dev/null +++ b/src/intervals_mcp_server/server.py @@ -0,0 +1,132 @@ +""" +Intervals.icu MCP Server + +This module implements a Model Context Protocol (MCP) server for connecting +Claude with the Intervals.icu API. It provides tools for retrieving and managing +athlete data, including activities, events, workouts, and wellness metrics. + +Main Features: + - Activity retrieval and detailed analysis + - Event management (races, workouts, calendar items) + - Wellness data tracking and visualization + - Error handling with user-friendly messages + - Configurable parameters with environment variable support + +Usage: + This server is designed to be run as a standalone script and exposes several MCP tools + for use with Claude Desktop or other MCP-compatible clients. The server loads configuration + from environment variables (optionally via a .env file) and communicates with the Intervals.icu API. + + To run the server: + $ python src/intervals_mcp_server/server.py + + MCP tools provided: + - get_activities + - get_activity_details + - get_activity_intervals + - get_activity_streams + - get_activity_messages + - add_activity_message + - get_events + - get_event_by_id + - add_or_update_event + - delete_event + - delete_events_by_date_range + - get_wellness_data + - get_athlete_power_curves + - get_custom_items + - get_custom_item_by_id + - create_custom_item + - update_custom_item + - delete_custom_item + + See the README for more details on configuration and usage. +""" + +import logging + +# Import API client and configuration +from intervals_mcp_server.api.client import ( + httpx_client, # Re-export for backward compatibility with tests + make_intervals_request, +) +from intervals_mcp_server.config import get_config +from intervals_mcp_server.mcp_instance import mcp + +# Import types and validation +from intervals_mcp_server.server_setup import setup_transport, start_server +from intervals_mcp_server.utils.validation import validate_athlete_id + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler()], +) +logger = logging.getLogger("intervals_icu_mcp_server") + +# Get configuration instance +config = get_config() + +# Import tool modules to register them (tools register themselves via @mcp.tool() decorators) +# Import tool functions for re-export +from intervals_mcp_server.tools.activities import ( # pylint: disable=wrong-import-position # noqa: E402 + add_activity_message, + get_activities, + get_activity_details, + get_activity_intervals, + get_activity_messages, + get_activity_streams, +) +from intervals_mcp_server.tools.events import ( # pylint: disable=wrong-import-position # noqa: E402 + add_or_update_event, + delete_event, + delete_events_by_date_range, + get_event_by_id, + get_events, +) +from intervals_mcp_server.tools.gear import get_gear_list # pylint: disable=wrong-import-position # noqa: E402 +from intervals_mcp_server.tools.wellness import get_wellness_data # pylint: disable=wrong-import-position # noqa: E402 +from intervals_mcp_server.tools.power_curves import get_athlete_power_curves # pylint: disable=wrong-import-position # noqa: E402 +from intervals_mcp_server.tools.custom_items import ( # pylint: disable=wrong-import-position # noqa: E402 + create_custom_item, + delete_custom_item, + get_custom_item_by_id, + get_custom_items, + update_custom_item, +) + +# Re-export make_intervals_request and httpx_client for backward compatibility +# pylint: disable=duplicate-code # This __all__ list is intentionally similar to tools/__init__.py +__all__ = [ + "make_intervals_request", + "httpx_client", # Re-exported for test compatibility + "add_activity_message", + "get_activities", + "get_activity_details", + "get_activity_intervals", + "get_activity_messages", + "get_activity_streams", + "get_events", + "get_event_by_id", + "delete_event", + "delete_events_by_date_range", + "add_or_update_event", + "get_wellness_data", + "get_athlete_power_curves", + "get_custom_items", + "get_custom_item_by_id", + "create_custom_item", + "update_custom_item", + "delete_custom_item", +] + + +# Run the server +if __name__ == "__main__": + # Validate ATHLETE_ID when server starts (not at import time to allow tests) + validate_athlete_id(config.athlete_id) + + # Setup transport and start server + selected_transport = setup_transport() + start_server(mcp, selected_transport) diff --git a/src/intervals_mcp_server/server_setup.py b/src/intervals_mcp_server/server_setup.py new file mode 100644 index 0000000..698b613 --- /dev/null +++ b/src/intervals_mcp_server/server_setup.py @@ -0,0 +1,78 @@ +""" +Server setup and initialization for Intervals.icu MCP Server. + +This module handles transport configuration and server startup logic. +""" + +import os +import logging + +from mcp.server.fastmcp import FastMCP # pylint: disable=import-error + +from intervals_mcp_server.utils.types import TransportAliases + +logger = logging.getLogger("intervals_icu_mcp_server") + + +def setup_transport() -> TransportAliases: + """ + Setup and validate the MCP transport configuration. + + Reads MCP_TRANSPORT environment variable and validates it against + supported transport types. + + Returns: + TransportAliases: The selected transport type. + + Raises: + ValueError: If the transport type is not supported. + """ + transport_env = os.getenv("MCP_TRANSPORT", TransportAliases.STDIO.value).lower() + try: + transport_alias = TransportAliases(transport_env) + except ValueError as exc: + allowed = ", ".join(item.value for item in TransportAliases) + raise ValueError(f"Unsupported MCP_TRANSPORT value. Use one of: {allowed}.") from exc + + # Map HTTP to STREAMABLE_HTTP + selected_transport = ( + TransportAliases.STREAMABLE_HTTP + if transport_alias == TransportAliases.HTTP + else transport_alias + ) + + return selected_transport + + +def start_server(mcp_instance: FastMCP, transport: TransportAliases) -> None: + """ + Start the MCP server with the specified transport. + + Args: + mcp_instance (FastMCP): The FastMCP server instance to start. + transport (TransportAliases): The transport type to use. + """ + host = mcp_instance.settings.host + port = mcp_instance.settings.port + + if transport == TransportAliases.STDIO: + logger.info("Starting MCP server with stdio transport.") + mcp_instance.run() + elif transport == TransportAliases.SSE: + mount_path = os.getenv("MCP_SSE_MOUNT_PATH") + logger.info( + "Starting MCP server with SSE transport at http://%s:%s%s (messages: %s).", + host, + port, + mcp_instance.settings.sse_path, + mcp_instance.settings.message_path, + ) + mcp_instance.run(transport="sse", mount_path=mount_path) + else: # STREAMABLE_HTTP + logger.info( + "Starting MCP server with Streamable HTTP transport at http://%s:%s%s.", + host, + port, + mcp_instance.settings.streamable_http_path, + ) + mcp_instance.run(transport="streamable-http") diff --git a/src/intervals_mcp_server/tools/__init__.py b/src/intervals_mcp_server/tools/__init__.py new file mode 100644 index 0000000..e9a4999 --- /dev/null +++ b/src/intervals_mcp_server/tools/__init__.py @@ -0,0 +1,73 @@ +""" +MCP tools registry for Intervals.icu MCP Server. + +This module registers all available MCP tools with the FastMCP server instance. +""" + +from mcp.server.fastmcp import FastMCP # pylint: disable=import-error + +# Import all tools for re-export +# Note: Tools register themselves via @mcp.tool() decorators when imported +from intervals_mcp_server.tools.activities import ( # noqa: F401 + get_activities, + get_activity_details, + get_activity_intervals, + get_activity_streams, +) +from intervals_mcp_server.tools.events import ( # noqa: F401 + add_or_update_event, + delete_event, + delete_events_by_date_range, + get_event_by_id, + get_events, +) +from intervals_mcp_server.tools.custom_items import ( # noqa: F401 + create_custom_item, + delete_custom_item, + get_custom_item_by_id, + get_custom_items, + update_custom_item, +) +from intervals_mcp_server.tools.power_curves import ( # noqa: F401 + get_athlete_power_curves, +) +from intervals_mcp_server.tools.gear import get_gear_list # noqa: F401 +from intervals_mcp_server.tools.wellness import get_wellness_data # noqa: F401 + + +def register_tools(mcp_instance: FastMCP) -> None: + """ + Register all MCP tools with the FastMCP server instance. + + This function imports all tool modules, which causes their @mcp.tool() + decorators to register the tools. The tools need access to the mcp instance, + so they will be imported after the mcp instance is created. + + Args: + mcp_instance (FastMCP): The FastMCP server instance to register tools with. + """ + # Tools are registered via decorators when modules are imported above + # The mcp_instance parameter is kept for future use if needed + _ = mcp_instance + + +__all__ = [ + "register_tools", + "get_activities", + "get_activity_details", + "get_activity_intervals", + "get_activity_streams", + "get_events", + "get_event_by_id", + "delete_event", + "delete_events_by_date_range", + "add_or_update_event", + "get_custom_items", + "get_custom_item_by_id", + "create_custom_item", + "update_custom_item", + "delete_custom_item", + "get_athlete_power_curves", + "get_gear_list", + "get_wellness_data", +] diff --git a/src/intervals_mcp_server/tools/activities.py b/src/intervals_mcp_server/tools/activities.py new file mode 100644 index 0000000..0fa5187 --- /dev/null +++ b/src/intervals_mcp_server/tools/activities.py @@ -0,0 +1,394 @@ +""" +Activity-related MCP tools for Intervals.icu. + +This module contains tools for retrieving and managing athlete activities. +""" + +from datetime import datetime, timedelta +from typing import Any + +from intervals_mcp_server.api.client import make_intervals_request +from intervals_mcp_server.config import get_config +from intervals_mcp_server.tools.gear import ( + resolve_gear_for_activity, + resolve_gear_for_activities, +) +from intervals_mcp_server.utils.formatting import format_activity_message, format_activity_summary, format_intervals +from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params + +# Import mcp instance from shared module for tool registration +from intervals_mcp_server.mcp_instance import mcp # noqa: F401 + +config = get_config() + + +def _parse_activities_from_result(result: Any) -> list[dict[str, Any]]: + """Extract a list of activity dictionaries from the API result.""" + activities: list[dict[str, Any]] = [] + + if isinstance(result, list): + activities = [item for item in result if isinstance(item, dict)] + elif isinstance(result, dict): + # Result is a single activity or a container + for _key, value in result.items(): + if isinstance(value, list): + activities = [item for item in value if isinstance(item, dict)] + break + # If no list was found but the dict has typical activity fields, treat it as a single activity + if not activities and any(key in result for key in ["name", "startTime", "distance"]): + activities = [result] + + return activities + + +def _filter_named_activities(activities: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Filter out unnamed activities from the list.""" + return [ + activity + for activity in activities + if activity.get("name") and activity.get("name") != "Unnamed" + ] + + +async def _fetch_more_activities( + athlete_id: str, + start_date: str, + api_key: str | None, + api_limit: int, +) -> list[dict[str, Any]]: + """Fetch additional activities from an earlier date range.""" + oldest_date = datetime.fromisoformat(start_date) + older_start_date = (oldest_date - timedelta(days=60)).strftime("%Y-%m-%d") + older_end_date = (oldest_date - timedelta(days=1)).strftime("%Y-%m-%d") + + if older_start_date >= older_end_date: + return [] + + more_params = { + "oldest": older_start_date, + "newest": older_end_date, + "limit": api_limit, + } + more_result = await make_intervals_request( + url=f"/athlete/{athlete_id}/activities", + api_key=api_key, + params=more_params, + ) + + if isinstance(more_result, list): + return _filter_named_activities(more_result) + return [] + + +def _format_activities_response( + activities: list[dict[str, Any]], + athlete_id: str, + include_unnamed: bool, +) -> str: + """Format the activities response based on the results.""" + if not activities: + if include_unnamed: + return ( + f"No valid activities found for athlete {athlete_id} in the specified date range." + ) + return f"No named activities found for athlete {athlete_id} in the specified date range. Try with include_unnamed=True to see all activities." + + # Format the output + activities_summary = "Activities:\n\n" + for activity in activities: + if isinstance(activity, dict): + activities_summary += format_activity_summary(activity) + "\n" + else: + activities_summary += f"Invalid activity format: {activity}\n\n" + + return activities_summary + + +@mcp.tool() +async def get_activities( # pylint: disable=too-many-arguments,too-many-return-statements,too-many-branches,too-many-positional-arguments + athlete_id: str | None = None, + api_key: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 10, + include_unnamed: bool = False, +) -> str: + """Get a list of activities for an athlete from Intervals.icu + + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago) + end_date: End date in YYYY-MM-DD format (optional, defaults to today) + limit: Maximum number of activities to return (optional, defaults to 10) + include_unnamed: Whether to include unnamed activities (optional, defaults to False) + """ + # Resolve athlete ID and date parameters + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + start_date, end_date = resolve_date_params(start_date, end_date) + + # Fetch more activities if we need to filter out unnamed ones + api_limit = limit * 3 if not include_unnamed else limit + + # Call the Intervals.icu API + params = {"oldest": start_date, "newest": end_date, "limit": api_limit} + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/activities", api_key=api_key, params=params + ) + + # Check for error + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching activities: {error_message}" + + if not result: + return f"No activities found for athlete {athlete_id_to_use} in the specified date range." + + # Parse activities from result + activities = _parse_activities_from_result(result) + + if not activities: + return f"No valid activities found for athlete {athlete_id_to_use} in the specified date range." + + # Filter and fetch more if needed + if not include_unnamed: + activities = _filter_named_activities(activities) + + # If we don't have enough named activities, try to fetch more + if len(activities) < limit: + more_activities = await _fetch_more_activities( + athlete_id_to_use, start_date, api_key, api_limit + ) + activities.extend(more_activities) + + # Limit to requested count + activities = activities[:limit] + + # Resolve gear names (in-place injection of `_resolved_gear_name`) + await resolve_gear_for_activities( + activities, athlete_id=athlete_id_to_use, api_key=api_key + ) + + return _format_activities_response(activities, athlete_id_to_use, include_unnamed) + + +@mcp.tool() +async def get_activity_details(activity_id: str, api_key: str | None = None) -> str: + """Get detailed information for a specific activity from Intervals.icu + + Args: + activity_id: The Intervals.icu activity ID + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + # Call the Intervals.icu API + result = await make_intervals_request(url=f"/activity/{activity_id}", api_key=api_key) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching activity details: {error_message}" + + # Format the response + if not result: + return f"No details found for activity {activity_id}." + + # If result is a list, use the first item if available + activity_data = result[0] if isinstance(result, list) and result else result + if not isinstance(activity_data, dict): + return f"Invalid activity format for activity {activity_id}." + + # Resolve gear name (uses configured athlete_id via ATHLETE_ID env var) + await resolve_gear_for_activity(activity_data, api_key=api_key) + + # Return a more detailed view of the activity + detailed_view = format_activity_summary(activity_data) + + # Add additional details if available + if "zones" in activity_data: + zones = activity_data["zones"] + detailed_view += "\nPower Zones:\n" + for zone in zones.get("power", []): + detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n" + + detailed_view += "\nHeart Rate Zones:\n" + for zone in zones.get("hr", []): + detailed_view += f"Zone {zone.get('number')}: {zone.get('secondsInZone')} seconds\n" + + return detailed_view + + +@mcp.tool() +async def get_activity_intervals(activity_id: str, api_key: str | None = None) -> str: + """Get interval data for a specific activity from Intervals.icu + + This endpoint returns detailed metrics for each interval in an activity, including power, heart rate, + cadence, speed, and environmental data. It also includes grouped intervals if applicable. + + Args: + activity_id: The Intervals.icu activity ID + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + # Call the Intervals.icu API + result = await make_intervals_request(url=f"/activity/{activity_id}/intervals", api_key=api_key) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching intervals: {error_message}" + + # Format the response + if not result: + return f"No interval data found for activity {activity_id}." + + # If the result is empty or doesn't contain expected fields + if not isinstance(result, dict) or not any( + key in result for key in ["icu_intervals", "icu_groups"] + ): + return f"No interval data or unrecognized format for activity {activity_id}." + + # Format the intervals data + return format_intervals(result) + + +@mcp.tool() +async def get_activity_streams( + activity_id: str, + api_key: str | None = None, + stream_types: str | None = None, +) -> str: + """Get stream data for a specific activity from Intervals.icu + + This endpoint returns time-series data for an activity, including metrics like power, heart rate, + cadence, altitude, distance, temperature, and velocity data. + + Args: + activity_id: The Intervals.icu activity ID + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + stream_types: Comma-separated list of stream types to retrieve (optional, defaults to all available types) + Available types: time, watts, heartrate, cadence, altitude, distance, + core_temperature, skin_temperature, velocity_smooth + """ + # Build query parameters + params = {} + if stream_types: + params["types"] = stream_types + else: + # Default to common stream types if none specified + params["types"] = "time,watts,heartrate,cadence,altitude,distance,velocity_smooth" + + # Call the Intervals.icu API + result = await make_intervals_request( + url=f"/activity/{activity_id}/streams", + api_key=api_key, + params=params, + ) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching activity streams: {error_message}" + + # Format the response + if not result: + return f"No stream data found for activity {activity_id}." + + # Ensure result is a list + streams = result if isinstance(result, list) else [] + + if not streams: + return f"No stream data found for activity {activity_id}." + + # Format the streams data + streams_summary = f"Activity Streams for {activity_id}:\n\n" + + for stream in streams: + if not isinstance(stream, dict): + continue + + stream_type = stream.get("type", "unknown") + stream_name = stream.get("name", stream_type) + data = stream.get("data", []) + value_type = stream.get("valueType", "") + + streams_summary += f"Stream: {stream_name} ({stream_type})\n" + streams_summary += f" Value Type: {value_type}\n" + streams_summary += f" Data Points: {len(data)}\n" + + # Show first few and last few data points for preview + if data: + if len(data) <= 10: + streams_summary += f" Values: {data}\n" + else: + preview_start = data[:5] + preview_end = data[-5:] + streams_summary += f" First 5 values: {preview_start}\n" + streams_summary += f" Last 5 values: {preview_end}\n" + + streams_summary += "\n" + + return streams_summary + + +@mcp.tool() +async def get_activity_messages(activity_id: str, api_key: str | None = None) -> str: + """Get messages (notes/comments) for a specific activity from Intervals.icu + + Args: + activity_id: The Intervals.icu activity ID + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + result = await make_intervals_request( + url=f"/activity/{activity_id}/messages", + api_key=api_key, + ) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching activity messages: {error_message}" + + if not result: + return f"No messages found for activity {activity_id}." + + messages = result if isinstance(result, list) else [] + if not messages: + return f"No messages found for activity {activity_id}." + + output = f"Messages for activity {activity_id}:\n\n" + for msg in messages: + if isinstance(msg, dict): + output += format_activity_message(msg) + "\n\n" + + return output + + +@mcp.tool() +async def add_activity_message( + activity_id: str, + content: str, + api_key: str | None = None, +) -> str: + """Add a message (note/comment) to an activity on Intervals.icu + + Args: + activity_id: The Intervals.icu activity ID + content: The message text to add + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + result = await make_intervals_request( + url=f"/activity/{activity_id}/messages", + api_key=api_key, + method="POST", + data={"content": content}, + ) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error adding message to activity: {error_message}" + + if not result or not isinstance(result, dict): + return "Error: Unexpected response when adding message." + + msg_id = result.get("id") + if msg_id is not None: + return f"Successfully added message (ID: {msg_id}) to activity {activity_id}." + return f"Message appears to have been added to activity {activity_id}, but no ID was returned. Please verify manually." diff --git a/src/intervals_mcp_server/tools/custom_items.py b/src/intervals_mcp_server/tools/custom_items.py new file mode 100644 index 0000000..172d41c --- /dev/null +++ b/src/intervals_mcp_server/tools/custom_items.py @@ -0,0 +1,232 @@ +""" +Custom items MCP tools for Intervals.icu. + +This module contains tools for managing athlete custom items (charts, fields, zones, etc.). +""" + +import json +from typing import Any + +from intervals_mcp_server.api.client import make_intervals_request +from intervals_mcp_server.config import get_config +from intervals_mcp_server.utils.formatting import format_custom_item_details +from intervals_mcp_server.utils.validation import resolve_athlete_id + +# Import mcp instance from shared module for tool registration +from intervals_mcp_server.mcp_instance import mcp # noqa: F401 + +config = get_config() + + +@mcp.tool() +async def get_custom_items( + athlete_id: str | None = None, + api_key: str | None = None, +) -> str: + """Get custom items (charts, custom fields, zones, etc.) for an athlete from Intervals.icu + + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/custom-item", api_key=api_key + ) + + if isinstance(result, dict) and "error" in result: + return f"Error fetching custom items: {result.get('message')}" + + if not result: + return f"No custom items found for athlete {athlete_id_to_use}." + + output = "Custom Items:\n\n" + for item in result: + if isinstance(item, dict): + output += f"- ID: {item.get('id')}\n" + output += f" Name: {item.get('name', 'N/A')}\n" + output += f" Type: {item.get('type', 'N/A')}\n" + if item.get("description"): + output += f" Description: {item['description']}\n" + output += "\n" + return output + + +@mcp.tool() +async def get_custom_item_by_id( + item_id: int, + athlete_id: str | None = None, + api_key: str | None = None, +) -> str: + """Get detailed information for a specific custom item from Intervals.icu + + Args: + item_id: The custom item ID + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", api_key=api_key + ) + + if isinstance(result, dict) and "error" in result: + return f"Error fetching custom item: {result.get('message')}" + + if not result or not isinstance(result, dict): + return f"No custom item found with ID {item_id}." + + return format_custom_item_details(result) + + +@mcp.tool() +async def create_custom_item( + name: str, + item_type: str, + athlete_id: str | None = None, + api_key: str | None = None, + description: str | None = None, + content: dict[str, Any] | None = None, + visibility: str | None = None, +) -> str: + """Create a new custom item for an athlete on Intervals.icu + + Args: + name: Name of the custom item + item_type: Type of custom item (e.g. FITNESS_CHART, TRACE_CHART, INPUT_FIELD, ACTIVITY_FIELD, INTERVAL_FIELD, ACTIVITY_STREAM, ACTIVITY_CHART, ACTIVITY_HISTOGRAM, ACTIVITY_HEATMAP, ACTIVITY_MAP, ACTIVITY_PANEL, ZONES) + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + description: Description of the custom item (optional) + content: Configuration content for the custom item as a dict (optional). Important enum values: + - "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number") + - "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG") + visibility: Visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + data: dict[str, Any] = {"name": name, "type": item_type} + if description is not None: + data["description"] = description + if content is not None: + if isinstance(content, str): + try: + content = json.loads(content) + except json.JSONDecodeError: + return "Error: content must be valid JSON when passed as a string." + data["content"] = content + if visibility is not None: + data["visibility"] = visibility + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/custom-item", + api_key=api_key, + data=data, + method="POST", + ) + + if isinstance(result, dict) and "error" in result: + return f"Error creating custom item: {result.get('message')}" + + if not result or not isinstance(result, dict): + return "Error: Unexpected response when creating custom item." + + return f"Successfully created custom item:\n\n{format_custom_item_details(result)}" + + +@mcp.tool() +async def update_custom_item( + item_id: int, + athlete_id: str | None = None, + api_key: str | None = None, + name: str | None = None, + item_type: str | None = None, + description: str | None = None, + content: dict[str, Any] | None = None, + visibility: str | None = None, +) -> str: + """Update an existing custom item for an athlete on Intervals.icu + + Args: + item_id: The custom item ID to update + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + name: New name for the custom item (optional) + item_type: New type for the custom item (optional) + description: New description for the custom item (optional) + content: New configuration content for the custom item as a dict (optional). Important enum values: + - "type" field for INPUT_FIELD/ACTIVITY_FIELD: must be "numeric", "text", or "select" (NOT "number") + - "aggregate" field: must be "MIN", "SUM", "MAX", or "AVERAGE" (NOT "AVG") + visibility: New visibility setting: PRIVATE, FOLLOWERS, or PUBLIC (optional) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + data: dict[str, Any] = {} + if name is not None: + data["name"] = name + if item_type is not None: + data["type"] = item_type + if description is not None: + data["description"] = description + if content is not None: + if isinstance(content, str): + try: + content = json.loads(content) + except json.JSONDecodeError: + return "Error: content must be valid JSON when passed as a string." + data["content"] = content + if visibility is not None: + data["visibility"] = visibility + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", + api_key=api_key, + data=data, + method="PUT", + ) + + if isinstance(result, dict) and "error" in result: + return f"Error updating custom item: {result.get('message')}" + + if not result or not isinstance(result, dict): + return "Error: Unexpected response when updating custom item." + + return f"Successfully updated custom item:\n\n{format_custom_item_details(result)}" + + +@mcp.tool() +async def delete_custom_item( + item_id: int, + athlete_id: str | None = None, + api_key: str | None = None, +) -> str: + """Delete a custom item for an athlete from Intervals.icu + + Args: + item_id: The custom item ID to delete + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/custom-item/{item_id}", + api_key=api_key, + method="DELETE", + ) + + if isinstance(result, dict) and "error" in result: + return f"Error deleting custom item: {result.get('message')}" + + return f"Successfully deleted custom item {item_id}." diff --git a/src/intervals_mcp_server/tools/events.py b/src/intervals_mcp_server/tools/events.py new file mode 100644 index 0000000..2ab93fe --- /dev/null +++ b/src/intervals_mcp_server/tools/events.py @@ -0,0 +1,433 @@ +""" +Event-related MCP tools for Intervals.icu. + +This module contains tools for retrieving, creating, updating, and deleting athlete events. +""" + +import json +from datetime import datetime +from typing import Any + +from intervals_mcp_server.api.client import make_intervals_request +from intervals_mcp_server.config import get_config +from intervals_mcp_server.utils.dates import get_default_end_date, get_default_future_end_date +from intervals_mcp_server.utils.formatting import format_event_details, format_event_summary +from intervals_mcp_server.utils.types import WorkoutDoc +from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id, validate_date + +# Import mcp instance from shared module for tool registration +from intervals_mcp_server.mcp_instance import mcp # noqa: F401 + +config = get_config() + + +def _prepare_event_data( # pylint: disable=too-many-arguments,too-many-positional-arguments + name: str, + workout_type: str, + start_date: str, + workout_doc: WorkoutDoc | None, + moving_time: int | None, + distance: int | None, +) -> dict[str, Any]: + """Prepare event data dictionary for API request. + + Many arguments are required to match the Intervals.icu API event structure. + """ + resolved_workout_type = resolve_activity_type(name, workout_type) + return { + "start_date_local": start_date + "T00:00:00", + "category": "WORKOUT", + "name": name, + "description": str(workout_doc) if workout_doc else None, + "type": resolved_workout_type, + "moving_time": moving_time, + "distance": distance, + } + + +def _handle_event_response( + result: dict[str, Any] | list[dict[str, Any]] | None, + action: str, + athlete_id: str, + start_date: str, +) -> str: + """Handle API response and format appropriate message.""" + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error {action} event: {error_message}" + if not result: + return f"No events {action} for athlete {athlete_id}." + if isinstance(result, dict): + return f"Successfully {action} event id: {result.get('id')}" + return f"Event {action} successfully at {start_date}" + + +async def _delete_events_list( + athlete_id: str, api_key: str | None, events: list[dict[str, Any]] +) -> list[int | str | None]: + """Delete a list of events and return IDs of failed deletions. + + Args: + athlete_id: The athlete ID. + api_key: Optional API key. + events: List of event dictionaries to delete. + + Returns: + List of event IDs that failed to delete. + """ + failed_events: list[int | str | None] = [] + for event in events: + result = await make_intervals_request( + url=f"/athlete/{athlete_id}/events/{event.get('id')}", + api_key=api_key, + method="DELETE", + ) + if isinstance(result, dict) and "error" in result: + failed_events.append(event.get("id")) + return failed_events + + +@mcp.tool() +async def get_events( + athlete_id: str | None = None, + api_key: str | None = None, + start_date: str | None = None, + end_date: str | None = None, +) -> str: + """Get events for an athlete from Intervals.icu + + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + start_date: Start date in YYYY-MM-DD format (optional, defaults to today) + end_date: End date in YYYY-MM-DD format (optional, defaults to 30 days from today) + """ + # Resolve athlete ID + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + # Parse date parameters (events use different defaults) + if not start_date: + start_date = get_default_end_date() + if not end_date: + end_date = get_default_future_end_date() + + # Call the Intervals.icu API + params = {"oldest": start_date, "newest": end_date} + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/events", api_key=api_key, params=params + ) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching events: {error_message}" + + # Format the response + if not result: + return f"No events found for athlete {athlete_id_to_use} in the specified date range." + + # Ensure result is a list + events = result if isinstance(result, list) else [] + + if not events: + return f"No events found for athlete {athlete_id_to_use} in the specified date range." + + events_summary = "Events:\n\n" + for event in events: + if not isinstance(event, dict): + continue + + events_summary += format_event_summary(event) + "\n\n" + + return events_summary + + +@mcp.tool() +async def get_event_by_id( + event_id: str, + athlete_id: str | None = None, + api_key: str | None = None, +) -> str: + """Get detailed information for a specific event from Intervals.icu + + Args: + event_id: The Intervals.icu event ID + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + """ + # Resolve athlete ID + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + # Call the Intervals.icu API + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/event/{event_id}", api_key=api_key + ) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching event details: {error_message}" + + # Format the response + if not result: + return f"No details found for event {event_id}." + + if not isinstance(result, dict): + return f"Invalid event format for event {event_id}." + + return format_event_details(result) + + +@mcp.tool() +async def delete_event( + event_id: str, + athlete_id: str | None = None, + api_key: str | None = None, +) -> str: + """Delete event for an athlete from Intervals.icu + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + event_id: The Intervals.icu event ID + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + if not event_id: + return "Error: No event ID provided." + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/events/{event_id}", api_key=api_key, method="DELETE" + ) + if isinstance(result, dict) and "error" in result: + return f"Error deleting event: {result.get('message')}" + return json.dumps(result, indent=2) + + +async def _fetch_events_for_deletion( + athlete_id: str, api_key: str | None, start_date: str, end_date: str +) -> tuple[list[dict[str, Any]], str | None]: + """Fetch events for deletion and return them with any error message. + + Args: + athlete_id: The athlete ID. + api_key: Optional API key. + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + + Returns: + Tuple of (events_list, error_message). error_message is None if successful. + """ + params = {"oldest": validate_date(start_date), "newest": validate_date(end_date)} + result = await make_intervals_request( + url=f"/athlete/{athlete_id}/events", api_key=api_key, params=params + ) + if isinstance(result, dict) and "error" in result: + return [], f"Error deleting events: {result.get('message')}" + events = result if isinstance(result, list) else [] + return events, None + + +@mcp.tool() +async def delete_events_by_date_range( + start_date: str, + end_date: str, + athlete_id: str | None = None, + api_key: str | None = None, +) -> str: + """Delete events for an athlete from Intervals.icu in the specified date range. + + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + start_date: Start date in YYYY-MM-DD format + end_date: End date in YYYY-MM-DD format + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + events, error_msg = await _fetch_events_for_deletion( + athlete_id_to_use, api_key, start_date, end_date + ) + if error_msg: + return error_msg + + failed_events = await _delete_events_list(athlete_id_to_use, api_key, events) + deleted_count = len(events) - len(failed_events) + return f"Deleted {deleted_count} events. Failed to delete {len(failed_events)} events: {failed_events}" + + +@mcp.tool() +async def add_or_update_event( # pylint: disable=too-many-arguments,too-many-positional-arguments + workout_type: str, + name: str, + athlete_id: str | None = None, + api_key: str | None = None, + event_id: str | None = None, + start_date: str | None = None, + workout_doc: WorkoutDoc | None = None, + moving_time: int | None = None, + distance: int | None = None, +) -> str: + """Post event for an athlete to Intervals.icu this follows the event api from intervals.icu + If event_id is provided, the event will be updated instead of created. + + Many arguments are required as this MCP tool function maps directly to the Intervals.icu API parameters. + + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + event_id: The Intervals.icu event ID (optional, will use event_id from .env if not provided) + start_date: Start date in YYYY-MM-DD format (optional, defaults to today) + name: Name of the activity + workout_doc: steps as a list of Step objects (optional, but necessary to define workout steps) + workout_type: Workout type (e.g. Ride, Run, Swim, Walk, Row) + moving_time: Total expected moving time of the workout in seconds (optional) + distance: Total expected distance of the workout in meters (optional) + + Example: + "workout_doc": { + "description": "High-intensity workout for increasing VO2 max", + "steps": [ + {"power": {"value": 80, "units": "%ftp"}, "duration": 900, "warmup": true}, + {"reps": 2, "text": "High-intensity intervals", "steps": [ + {"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"}, + {"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"} + ]}, + {"power": {"value": 80, "units": "%ftp"}, "duration": 600, "cooldown": true}, + {"text": ""} + ] + } + + Step properties: + distance: Distance of step in meters + {"distance": 5000} + duration: Duration of step in seconds + {"duration": 1800} + power/hr/pace/cadence: Define step intensity + Percentage of FTP: {"power": {"value": 80, "units": "%ftp"}} + Absolute power: {"power": {"value": 200, "units": "w"}} + Heart rate: {"hr": {"value": 75, "units": "%hr"}} + Heart rate (LTHR): {"hr": {"value": 85, "units": "%lthr"}} + Cadence: {"cadence": {"value": 90, "units": "cadence"}} + Pace by ftp: {"pace": {"value": 80, "units": "%pace"}} + Pace by zone: {"pace": {"value": 2, "units": "pace_zone"}} + Zone by power: {"power": {"value": 2, "units": "power_zone"}} + Zone by heart rate: {"hr": {"value": 2, "units": "hr_zone"}} + Ranges: Specify ranges for power, heart rate, or cadence: + {"power": {"start": 80, "end": 90, "units": "%ftp"}} + Ramps: Instead of a range, indicate a gradual change in intensity (useful for ERG workouts): + {"ramp": true, "power": {"start": 80, "end": 90, "units": "%ftp"}} + Repeats: include the reps property and add nested steps + {"reps": 3, + "steps": [ + {"power": {"value": 110, "units": "%ftp"}, "distance": 500, "text": "High-intensity"}, + {"power": {"value": 80, "units": "%ftp"}, "duration": 90, "text": "Recovery"} + ]} + Free Ride: Include freeride to indicate a segment without ERG control, optionally with a suggested power range: + {"freeride": true, "power": {"value": 80, "units": "%ftp"}} + Comments and Labels: Add descriptive text to label steps: + {"text": "Warmup"} + + How to use steps: + - Set distance or duration as appropriate for step + - Use "reps" with nested steps to define repeat intervals (as in example above) + - Define one of "power", "hr" or "pace" to define step intensity + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + if not start_date: + start_date = datetime.now().strftime("%Y-%m-%d") + + try: + validated_date = validate_date(start_date) + event_data = _prepare_event_data( + name, workout_type, validated_date, workout_doc, moving_time, distance + ) + return await _create_or_update_event_request( + athlete_id_to_use, api_key, event_data, validated_date, event_id + ) + except ValueError as e: + return f"Error: {e}" + + +@mcp.tool() +async def add_or_update_note( + name: str, + description: str, + start_date: str | None = None, + color: str | None = "green", + athlete_id: str | None = None, + api_key: str | None = None, + event_id: str | None = None, +) -> str: + """Add or update a plain text note (category NOTE) on the Intervals.icu calendar. + + Args: + name: Title of the note + description: Plain text content of the note + start_date: Date in YYYY-MM-DD format (optional, defaults to today) + color: Color of the note (e.g. green, orange, red, blue) + athlete_id: The Intervals.icu athlete ID (optional) + api_key: The Intervals.icu API key (optional) + event_id: The Intervals.icu event ID (optional, for updates) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + if not start_date: + start_date = datetime.now().strftime("%Y-%m-%d") + + try: + validated_date = validate_date(start_date) + event_data = { + "category": "NOTE", + "name": name, + "description": description, + "start_date_local": validated_date + "T00:00:00", + "color": color + } + + return await _create_or_update_event_request( + athlete_id_to_use, api_key, event_data, validated_date, event_id + ) + except ValueError as e: + return f"Error: {e}" + + +async def _create_or_update_event_request( + athlete_id: str, + api_key: str | None, + event_data: dict[str, Any], + start_date: str, + event_id: str | None, +) -> str: + """Create or update an event via API request. + + Args: + athlete_id: The athlete ID. + api_key: Optional API key. + event_data: Prepared event data dictionary. + start_date: Start date string for response formatting. + event_id: Optional event ID for updates. + + Returns: + Formatted response string. + """ + url = f"/athlete/{athlete_id}/events" + if event_id: + url += f"/{event_id}" + result = await make_intervals_request( + url=url, + api_key=api_key, + data=event_data, + method="PUT" if event_id else "POST", + ) + action = "updated" if event_id else "created" + return _handle_event_response(result, action, athlete_id, start_date) diff --git a/src/intervals_mcp_server/tools/gear.py b/src/intervals_mcp_server/tools/gear.py new file mode 100644 index 0000000..adddde4 --- /dev/null +++ b/src/intervals_mcp_server/tools/gear.py @@ -0,0 +1,198 @@ +""" +Gear-related MCP tools for Intervals.icu. + +This module provides: +- A module-level cache of the athlete's raw gear catalog (bikes, shoes, etc.) + to avoid hitting the /athlete/{id}/gear endpoint on every activity lookup. +- A helper to inject the human-readable gear name into an activity dict (under + `_resolved_gear_name`), which the formatter then displays in the `Gear:` block. +- A user-facing MCP tool `get_gear_list` so the assistant can discover or + refresh the gear catalog on demand. + +Intervals.icu's activity payload includes only the gear ID (e.g. `b16177481`) +but not the gear name. The gear name lives in a separate endpoint +`/athlete/{athlete_id}/gear` that returns the full catalog. To avoid an extra +round-trip per activity, we cache the raw gear catalog per athlete for the +lifetime of the MCP server process and derive the `{id: name}` lookup from it. +Call `get_gear_list(refresh=True)` to bust the cache. +""" + +from typing import Any + +from intervals_mcp_server.api.client import make_intervals_request +from intervals_mcp_server.config import get_config +from intervals_mcp_server.utils.validation import resolve_athlete_id + +# Import mcp instance from shared module for tool registration +from intervals_mcp_server.mcp_instance import mcp # noqa: F401 + +config = get_config() + +# Module-level cache of the raw gear catalog per athlete. Single source of +# truth: the id->name map and the rich listing are both derived from this. +_GEAR_RAW_CACHE: dict[str, list[dict[str, Any]]] = {} + + +def _extract_gear_id(activity: dict[str, Any]) -> str | None: + """Pull the gear ID out of an activity dict, handling the two known shapes.""" + gear_raw = activity.get("gear") + if isinstance(gear_raw, dict): + gear_id = gear_raw.get("id") + if gear_id: + return str(gear_id) + gear_id = activity.get("gear_id") + if gear_id: + return str(gear_id) + return None + + +def _items_from_response(result: Any) -> list[dict[str, Any]]: + """Normalize the /athlete/{id}/gear response into a list of gear dicts.""" + if isinstance(result, list): + return [item for item in result if isinstance(item, dict)] + if isinstance(result, dict): + # Some endpoints wrap the list in a container; pull any list value. + for value in result.values(): + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def _derive_gear_map(items: list[dict[str, Any]]) -> dict[str, str]: + """Convert a raw gear list into a {gear_id: gear_name} lookup.""" + gear_map: dict[str, str] = {} + for item in items: + gid = item.get("id") + name = item.get("name") or item.get("display_name") + if gid and name: + gear_map[str(gid)] = str(name) + return gear_map + + +async def get_gear_raw( + athlete_id: str | None = None, + api_key: str | None = None, + *, + refresh: bool = False, +) -> list[dict[str, Any]]: + """Return (and cache) the raw gear list for an athlete. + + Single source of truth that backs both the id->name map and the rich + listing produced by `get_gear_list`. One API call per athlete per process + lifetime unless `refresh=True`. + + Args: + athlete_id: Athlete to look up. Defaults to ATHLETE_ID env var via config. + api_key: Override the configured API key. + refresh: If True, ignore the cache and re-fetch from the API. + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg or not athlete_id_to_use: + return [] + + if not refresh and athlete_id_to_use in _GEAR_RAW_CACHE: + return _GEAR_RAW_CACHE[athlete_id_to_use] + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/gear", api_key=api_key + ) + items = _items_from_response(result) + _GEAR_RAW_CACHE[athlete_id_to_use] = items + return items + + +async def get_gear_map( + athlete_id: str | None = None, + api_key: str | None = None, + *, + refresh: bool = False, +) -> dict[str, str]: + """Return the {gear_id: gear_name} lookup for an athlete (derived from cache).""" + items = await get_gear_raw(athlete_id=athlete_id, api_key=api_key, refresh=refresh) + return _derive_gear_map(items) + + +async def resolve_gear_for_activity( + activity: dict[str, Any], + athlete_id: str | None = None, + api_key: str | None = None, +) -> None: + """Inject `_resolved_gear_name` into an activity dict if gear info is present. + + Mutates the activity dict in place. Safe to call when gear is absent (no-op). + Uses the cached gear map; the first call per athlete triggers a fetch. + """ + gear_id = _extract_gear_id(activity) + if not gear_id: + return + + gear_map = await get_gear_map(athlete_id=athlete_id, api_key=api_key) + name = gear_map.get(gear_id) + if name: + activity["_resolved_gear_name"] = name + + +async def resolve_gear_for_activities( + activities: list[dict[str, Any]], + athlete_id: str | None = None, + api_key: str | None = None, +) -> None: + """Inject `_resolved_gear_name` into each activity in a list. In-place.""" + if not activities: + return + # Pre-warm the cache once, then iterate. + _ = await get_gear_map(athlete_id=athlete_id, api_key=api_key) + for activity in activities: + if isinstance(activity, dict): + await resolve_gear_for_activity( + activity, athlete_id=athlete_id, api_key=api_key + ) + + +@mcp.tool() +async def get_gear_list( + athlete_id: str | None = None, + api_key: str | None = None, + refresh: bool = False, +) -> str: + """Get the gear catalog (bikes, shoes, etc.) for an athlete from Intervals.icu. + + Returns one line per gear item with id, type, name, and basic stats. + The result is cached for the MCP process lifetime; pass refresh=True to + re-fetch. + + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + refresh: If True, bypass the cache and re-fetch from the API (default False) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + if not athlete_id_to_use: + return "Error: athlete_id is required (either as argument or via ATHLETE_ID env var)." + + # Single fetch path: get_gear_raw consults the cache and only hits the API + # on a cold cache or when refresh=True. + items = await get_gear_raw( + athlete_id=athlete_id_to_use, api_key=api_key, refresh=refresh + ) + + if not items: + return f"No gear found for athlete {athlete_id_to_use}." + + output = f"Gear catalog for athlete {athlete_id_to_use}:\n\n" + output += f"{'ID':<14} {'Type':<8} {'Name':<32} {'Default':<8} {'Acts':<6} {'Dist (km)':<10} {'Retired':<8}\n" + output += f"{'-' * 14} {'-' * 8} {'-' * 32} {'-' * 8} {'-' * 6} {'-' * 10} {'-' * 8}\n" + for it in items: + gid = str(it.get("id", "?")) + gtype = str(it.get("component_type", it.get("type", "?"))) + name = str(it.get("name", "?"))[:32] + default_for = it.get("default_for_type") or it.get("default_for") or "" + acts = str(it.get("activities", it.get("activity_count", "?"))) + dist_m = it.get("distance", 0) or 0 + dist_km = f"{dist_m / 1000:.1f}" if isinstance(dist_m, (int, float)) else "?" + retired = "yes" if it.get("retired") else "" + output += f"{gid:<14} {gtype:<8} {name:<32} {str(default_for):<8} {acts:<6} {dist_km:<10} {retired:<8}\n" + + return output diff --git a/src/intervals_mcp_server/tools/power_curves.py b/src/intervals_mcp_server/tools/power_curves.py new file mode 100644 index 0000000..c3ea268 --- /dev/null +++ b/src/intervals_mcp_server/tools/power_curves.py @@ -0,0 +1,214 @@ +""" +Power curve MCP tools for Intervals.icu. + +This module contains tools for retrieving athlete power curve data. +""" + +import json +from datetime import datetime +from typing import Any + +from intervals_mcp_server.api.client import make_intervals_request +from intervals_mcp_server.config import get_config +from intervals_mcp_server.utils.formatting import format_power_curves +from intervals_mcp_server.utils.validation import resolve_activity_type, resolve_athlete_id + +# Import mcp instance from shared module for tool registration +from intervals_mcp_server.mcp_instance import mcp # noqa: F401 + +config = get_config() + +# 5s, 15s, 30s, 1min, 2min, 5min, 10min, 20min, 60min +DEFAULT_DURATIONS: tuple[int, ...] = (5, 15, 30, 60, 120, 300, 600, 1200, 3600) + + +def _build_curves_param( + this_season: bool, + last_season: bool, + start_date: str | None, + end_date: str | None, +) -> list[str]: + """Build the curves query parameter list based on user selections. + + Args: + this_season: Whether to include this season's curve. + last_season: Whether to include last season's curve. + start_date: Optional start date for a custom date range curve. + end_date: Optional end date for a custom date range curve. + + Returns: + List of curve identifiers for the API request. + """ + curves: list[str] = [] + if this_season: + curves.append("s0") + if last_season: + curves.append("s1") + if start_date and end_date: + curves.append(f"r.{start_date}.{end_date}") + return curves + + +def _validate_dates(start_date: str | None, end_date: str | None) -> str | None: + """Validate that start_date and end_date are either both provided or both absent. + + Returns: + An error message if validation fails, otherwise None. + """ + if (start_date is None) != (end_date is None): + return "Error: Both start_date and end_date must be provided together for a custom date range." + if start_date and end_date: + try: + s = datetime.strptime(start_date, "%Y-%m-%d") + e = datetime.strptime(end_date, "%Y-%m-%d") + if s >= e: + return "Error: start_date must be before end_date." + except ValueError: + return "Error: Dates must be in YYYY-MM-DD format." + return None + + +def _extract_curve_data( + curve: dict[str, Any], + durations: list[int], + include_normalised: bool, +) -> dict[str, Any]: + """Extract power data for requested durations from a single curve. + + Args: + curve: A single curve object from the API response. + durations: List of durations in seconds to extract. + include_normalised: Whether to include W/kg data. + + Returns: + Dictionary with curve metadata and extracted data points. + """ + secs = curve.get("secs", []) + values = curve.get("values", []) + activity_ids = curve.get("activity_id", []) + watts_per_kg = curve.get("watts_per_kg", []) + wkg_activity_ids = curve.get("wkg_activity_id", []) + + # Build a lookup from seconds to index for efficient access + sec_to_idx: dict[int, int] = {s: i for i, s in enumerate(secs)} + + data_points: list[dict[str, Any]] = [] + for dur in durations: + idx = sec_to_idx.get(dur) + if idx is None or idx >= len(values): + continue + point: dict[str, Any] = { + "secs": dur, + "watts": values[idx], + "activity_id": ( + activity_ids[idx] + if idx < len(activity_ids) and activity_ids[idx] is not None + else "" + ), + } + if include_normalised and idx < len(watts_per_kg): + point["watts_per_kg"] = round(watts_per_kg[idx], 2) + point["wkg_activity_id"] = ( + wkg_activity_ids[idx] + if idx < len(wkg_activity_ids) + and wkg_activity_ids[idx] is not None + else "" + ) + data_points.append(point) + + return { + "id": curve.get("id", ""), + "label": curve.get("label", curve.get("id", "")), + "start": curve.get("start_date_local", ""), + "end": curve.get("end_date_local", ""), + "data_points": data_points, + } + + +@mcp.tool() +async def get_athlete_power_curves( + activity_type: str = "Ride", + durations: list[int] | None = None, + indoor_outdoor: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + this_season: bool = True, + last_season: bool = True, + include_normalised: bool = True, + athlete_id: str | None = None, + api_key: str | None = None, +) -> str: + """Get power curves for an athlete from Intervals.icu. + + Returns best power output for selected durations across specified time periods. + Uses FFT power computation. Power values are in watts. + + Args: + activity_type: Activity type (e.g. "Ride", "Run", "VirtualRide"). Default is "Ride". + durations: Durations in seconds to include. Default is [5, 15, 30, 60, 120, 300, 600, 1200, 3600] + indoor_outdoor: Filter by location — "indoor" or "outdoor". Omit for no filtering. + start_date: Start date (YYYY-MM-DD) for custom date range curve. Must be used with end_date. + end_date: End date (YYYY-MM-DD) for custom date range curve. Must be used with start_date. + this_season: Include this season's curve (default True) + last_season: Include last season's curve (default True) + include_normalised: Include weight-normalised W/kg values (default True) + athlete_id: Intervals.icu athlete ID (optional, uses ATHLETE_ID from .env if not provided) + api_key: Optional API key override. Uses API_KEY from .env if not provided. + """ + if durations is None: + durations = list(DEFAULT_DURATIONS) + + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + if indoor_outdoor and indoor_outdoor not in ("indoor", "outdoor"): + return "Error: indoor_outdoor must be 'indoor', 'outdoor', or omitted." + + date_error = _validate_dates(start_date, end_date) + if date_error: + return date_error + + curves = _build_curves_param(this_season, last_season, start_date, end_date) + if not curves: + return "Error: At least one curve must be selected (this_season, last_season, or a date range)." + + params: dict[str, Any] = { + "curves": curves, + "type": activity_type, + "includeRanks": False, + } + if indoor_outdoor: + params["filters"] = json.dumps( + [{"field_id": "indoor", "value": indoor_outdoor, "id": 1}] + ) + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/power-curves", + params=params, + api_key=api_key, + ) + + if isinstance(result, dict) and "error" in result: + error_message = result.get("message", "Unknown error") + return f"Error fetching power curves: {error_message}" + + # Response has a "list" key containing curve objects + curve_list: list[dict[str, Any]] = [] + if isinstance(result, dict): + curve_list = result.get("list", []) + elif isinstance(result, list): + curve_list = result + + if not curve_list: + return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})." + + extracted: list[dict[str, Any]] = [] + for curve in curve_list: + if isinstance(curve, dict): + extracted.append(_extract_curve_data(curve, durations, include_normalised)) + + if not extracted: + return f"No power curve data found for athlete {athlete_id_to_use} ({activity_type})." + + return format_power_curves(extracted, activity_type, include_normalised) diff --git a/src/intervals_mcp_server/tools/wellness.py b/src/intervals_mcp_server/tools/wellness.py new file mode 100644 index 0000000..5ff5285 --- /dev/null +++ b/src/intervals_mcp_server/tools/wellness.py @@ -0,0 +1,71 @@ +""" +Wellness-related MCP tools for Intervals.icu. + +This module contains tools for retrieving athlete wellness data. +""" + +from intervals_mcp_server.api.client import make_intervals_request +from intervals_mcp_server.config import get_config +from intervals_mcp_server.utils.formatting import format_wellness_entry +from intervals_mcp_server.utils.validation import resolve_athlete_id, resolve_date_params + +# Import mcp instance from shared module for tool registration +from intervals_mcp_server.mcp_instance import mcp # noqa: F401 + +config = get_config() + + +@mcp.tool() +async def get_wellness_data( + athlete_id: str | None = None, + api_key: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + include_all_fields: bool = False, +) -> str: + """Get wellness data for an athlete from Intervals.icu. + + By default returns standard wellness fields (training metrics, vitals, sleep, + subjective scores, etc.). Set include_all_fields=True to also include any + additional or custom fields configured by the user in Intervals.icu. + + Args: + athlete_id: The Intervals.icu athlete ID (optional, will use ATHLETE_ID from .env if not provided) + api_key: The Intervals.icu API key (optional, will use API_KEY from .env if not provided) + start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago) + end_date: End date in YYYY-MM-DD format (optional, defaults to today) + include_all_fields: If True, include additional and custom fields beyond the standard set (optional, defaults to False) + """ + athlete_id_to_use, error_msg = resolve_athlete_id(athlete_id, config.athlete_id) + if error_msg: + return error_msg + + start_date, end_date = resolve_date_params(start_date, end_date) + + params = {"oldest": start_date, "newest": end_date} + + result = await make_intervals_request( + url=f"/athlete/{athlete_id_to_use}/wellness", api_key=api_key, params=params + ) + + if isinstance(result, dict) and "error" in result: + return f"Error fetching wellness data: {result.get('message')}" + + if not result: + return ( + f"No wellness data found for athlete {athlete_id_to_use} in the specified date range." + ) + + wellness_summary = "Wellness Data:\n\n" + + if isinstance(result, dict): + for date_str, data in result.items(): + if isinstance(data, dict) and "date" not in data: + data["date"] = date_str + wellness_summary += format_wellness_entry(data, include_all_fields=include_all_fields) + "\n\n" + elif isinstance(result, list): + for entry in result: + if isinstance(entry, dict): + wellness_summary += format_wellness_entry(entry, include_all_fields=include_all_fields) + "\n\n" + + return wellness_summary diff --git a/src/intervals_mcp_server/utils/dates.py b/src/intervals_mcp_server/utils/dates.py new file mode 100644 index 0000000..3987a92 --- /dev/null +++ b/src/intervals_mcp_server/utils/dates.py @@ -0,0 +1,64 @@ +""" +Date utility functions for Intervals.icu MCP Server. + +This module provides helper functions for date parsing and default date calculations. +""" + +from datetime import datetime, timedelta + + +def get_default_start_date(days_ago: int = 30) -> str: + """ + Get a default start date string in YYYY-MM-DD format. + + Args: + days_ago: Number of days ago from today. Defaults to 30. + + Returns: + Date string in YYYY-MM-DD format. + """ + return (datetime.now() - timedelta(days=days_ago)).strftime("%Y-%m-%d") + + +def get_default_end_date() -> str: + """ + Get today's date string in YYYY-MM-DD format. + + Returns: + Date string in YYYY-MM-DD format. + """ + return datetime.now().strftime("%Y-%m-%d") + + +def get_default_future_end_date(days_ahead: int = 30) -> str: + """ + Get a default future end date string in YYYY-MM-DD format. + + Args: + days_ahead: Number of days ahead from today. Defaults to 30. + + Returns: + Date string in YYYY-MM-DD format. + """ + return (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d") + + +def parse_date_range( + start_date: str | None, end_date: str | None, default_start_days_ago: int = 30 +) -> tuple[str, str]: + """ + Parse and validate a date range, providing defaults if needed. + + Args: + start_date: Start date in YYYY-MM-DD format (optional). + end_date: End date in YYYY-MM-DD format (optional). + default_start_days_ago: Number of days ago for default start date. Defaults to 30. + + Returns: + Tuple of (start_date, end_date) as strings in YYYY-MM-DD format. + """ + if not start_date: + start_date = get_default_start_date(default_start_days_ago) + if not end_date: + end_date = get_default_end_date() + return start_date, end_date diff --git a/src/intervals_mcp_server/utils/formatting.py b/src/intervals_mcp_server/utils/formatting.py new file mode 100644 index 0000000..2e68a81 --- /dev/null +++ b/src/intervals_mcp_server/utils/formatting.py @@ -0,0 +1,660 @@ +""" +Formatting utilities for Intervals.icu MCP Server + +This module contains formatting functions for handling data from the Intervals.icu API. +""" + +import json +from datetime import datetime +from typing import Any + + +class _KeyTracker(dict): + """A dict wrapper that records which keys are accessed.""" + + def __init__(self, data: dict[str, Any]) -> None: + super().__init__(data) + self.accessed: set[str] = set() + + def get(self, key: str, default: Any = None) -> Any: + self.accessed.add(key) + return super().get(key, default) + + def __getitem__(self, key: str) -> Any: + self.accessed.add(key) + return super().__getitem__(key) + + def __contains__(self, key: object) -> bool: + if isinstance(key, str): + self.accessed.add(key) + return super().__contains__(key) + + +def format_activity_summary(activity: dict[str, Any]) -> str: + """Format an activity into a readable string.""" + start_time = activity.get("startTime", activity.get("start_date", "Unknown")) + + if isinstance(start_time, str) and len(start_time) > 10: + # Format datetime if it's a full ISO string + try: + dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) + start_time = dt.strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + pass + + rpe = activity.get("perceived_exertion", None) + if rpe is None: + rpe = activity.get("icu_rpe", "N/A") + if isinstance(rpe, (int, float)): + rpe = f"{rpe}/10" + + feel = activity.get("feel", "N/A") + if isinstance(feel, int): + feel = f"{feel}/5" + + # Gear (bike, shoes) - ICU activity payloads include the gear ID but not the + # gear name (which lives in /athlete/{id}/gear). The tools.gear module + # resolves the name and injects it as `_resolved_gear_name` before this + # formatter runs. Prefer the resolved name; otherwise fall back to whatever + # the raw payload provides (typically just an ID). + resolved_name = activity.get("_resolved_gear_name") + gear_raw = activity.get("gear") + if resolved_name: + gear_name = resolved_name + if isinstance(gear_raw, dict): + gear_id = gear_raw.get("id", activity.get("gear_id", "N/A")) + else: + gear_id = activity.get("gear_id", "N/A") + elif isinstance(gear_raw, dict): + gear_name = gear_raw.get("name") or gear_raw.get("display_name") or "N/A" + gear_id = gear_raw.get("id", "N/A") + else: + gear_name = activity.get("gear_name", "N/A") + gear_id = activity.get("gear_id", "N/A") + + return f""" +Activity: {activity.get("name", "Unnamed")} +ID: {activity.get("id", "N/A")} +Type: {activity.get("type", "Unknown")} +Date: {start_time} +Description: {activity.get("description", "N/A")} +Distance: {activity.get("distance", 0)} meters +Duration: {activity.get("duration", activity.get("elapsed_time", 0))} seconds +Moving Time: {activity.get("moving_time", "N/A")} seconds +Elevation Gain: {activity.get("elevationGain", activity.get("total_elevation_gain", 0))} meters +Elevation Loss: {activity.get("total_elevation_loss", "N/A")} meters + +Power Data: +Average Power: {activity.get("avgPower", activity.get("icu_average_watts", activity.get("average_watts", "N/A")))} watts +Weighted Avg Power: {activity.get("icu_weighted_avg_watts", "N/A")} watts +Training Load: {activity.get("trainingLoad", activity.get("icu_training_load", "N/A"))} +FTP: {activity.get("icu_ftp", "N/A")} watts +Kilojoules: {activity.get("icu_joules", "N/A")} +Intensity: {activity.get("icu_intensity", "N/A")} +Power:HR Ratio: {activity.get("icu_power_hr", "N/A")} +Variability Index: {activity.get("icu_variability_index", "N/A")} + +Heart Rate Data: +Average Heart Rate: {activity.get("avgHr", activity.get("average_heartrate", "N/A"))} bpm +Max Heart Rate: {activity.get("max_heartrate", "N/A")} bpm +LTHR: {activity.get("lthr", "N/A")} bpm +Resting HR: {activity.get("icu_resting_hr", "N/A")} bpm +Decoupling: {activity.get("decoupling", "N/A")} + +Other Metrics: +Cadence: {activity.get("average_cadence", "N/A")} rpm +Calories burned: {activity.get("calories", "N/A")} kcal +Average Speed: {activity.get("average_speed", "N/A")} m/s +Max Speed: {activity.get("max_speed", "N/A")} m/s +Average Stride: {activity.get("average_stride", "N/A")} +L/R Balance: {activity.get("avg_lr_balance", "N/A")} +Weight: {activity.get("icu_weight", "N/A")} kg +RPE: {rpe} +Session RPE: {activity.get("session_rpe", "N/A")} +Feel: {feel} + +Environment: +Trainer: {activity.get("trainer", "N/A")} +Average Temp: {activity.get("average_temp", "N/A")}°C +Min Temp: {activity.get("min_temp", "N/A")}°C +Max Temp: {activity.get("max_temp", "N/A")}°C +Avg Wind Speed: {activity.get("average_wind_speed", "N/A")} km/h +Headwind %: {activity.get("headwind_percent", "N/A")}% +Tailwind %: {activity.get("tailwind_percent", "N/A")}% + +Training Metrics: +Fitness (CTL): {activity.get("icu_ctl", "N/A")} +Fatigue (ATL): {activity.get("icu_atl", "N/A")} +TRIMP: {activity.get("trimp", "N/A")} +Polarization Index: {activity.get("polarization_index", "N/A")} +Power Load: {activity.get("power_load", "N/A")} +HR Load: {activity.get("hr_load", "N/A")} +Pace Load: {activity.get("pace_load", "N/A")} +Efficiency Factor: {activity.get("icu_efficiency_factor", "N/A")} + +Device Info: +Device: {activity.get("device_name", "N/A")} +Power Meter: {activity.get("power_meter", "N/A")} +File Type: {activity.get("file_type", "N/A")} + +Gear: +Name: {gear_name} +ID: {gear_id} +""" + + +def format_workout(workout: dict[str, Any]) -> str: + """Format a workout into a readable string.""" + return f""" +Workout: {workout.get("name", "Unnamed")} +Description: {workout.get("description", "No description")} +Sport: {workout.get("sport", "Unknown")} +Duration: {workout.get("duration", 0)} seconds +TSS: {workout.get("tss", "N/A")} +Intervals: {len(workout.get("intervals", []))} +""" + + +def _format_training_metrics(entries: dict[str, Any]) -> list[str]: + """Format training metrics section.""" + training_metrics = [] + for k, label in [ + ("ctl", "Fitness (CTL)"), + ("atl", "Fatigue (ATL)"), + ("rampRate", "Ramp Rate"), + ("ctlLoad", "CTL Load"), + ("atlLoad", "ATL Load"), + ]: + if entries.get(k) is not None: + training_metrics.append(f"- {label}: {entries[k]}") + return training_metrics + + +def _format_sport_info(entries: dict[str, Any]) -> list[str]: + """Format sport-specific info section.""" + sport_info_list = [] + if entries.get("sportInfo"): + for sport in entries.get("sportInfo", []): + if isinstance(sport, dict) and sport.get("eftp") is not None: + sport_info_list.append(f"- {sport.get('type')}: eFTP = {sport['eftp']}") + return sport_info_list + + +def _format_vital_signs(entries: dict[str, Any]) -> list[str]: + """Format vital signs section.""" + vital_signs = [] + for k, label, unit in [ + ("weight", "Weight", "kg"), + ("restingHR", "Resting HR", "bpm"), + ("hrv", "HRV", ""), + ("hrvSDNN", "HRV SDNN", ""), + ("avgSleepingHR", "Average Sleeping HR", "bpm"), + ("spO2", "SpO2", "%"), + ("systolic", "Systolic BP", ""), + ("diastolic", "Diastolic BP", ""), + ("respiration", "Respiration", "breaths/min"), + ("bloodGlucose", "Blood Glucose", "mmol/L"), + ("lactate", "Lactate", "mmol/L"), + ("vo2max", "VO2 Max", "ml/kg/min"), + ("bodyFat", "Body Fat", "%"), + ("abdomen", "Abdomen", "cm"), + ("baevskySI", "Baevsky Stress Index", ""), + ]: + if entries.get(k) is not None: + value = entries[k] + if k == "systolic" and entries.get("diastolic") is not None: + vital_signs.append( + f"- Blood Pressure: {entries['systolic']}/{entries['diastolic']} mmHg" + ) + elif k not in ("systolic", "diastolic"): + vital_signs.append(f"- {label}: {value}{(' ' + unit) if unit else ''}") + return vital_signs + + +def _format_sleep_recovery(entries: dict[str, Any]) -> list[str]: + """Format sleep and recovery section.""" + sleep_lines = [] + sleep_hours = None + if entries.get("sleepSecs") is not None: + sleep_hours = f"{entries['sleepSecs'] / 3600:.2f}" + elif entries.get("sleepHours") is not None: + sleep_hours = f"{entries['sleepHours']}" + if sleep_hours is not None: + sleep_lines.append(f" Sleep: {sleep_hours} hours") + + if entries.get("sleepQuality") is not None: + quality_value = entries["sleepQuality"] + quality_labels = {1: "Great", 2: "Good", 3: "Average", 4: "Poor"} + quality_text = quality_labels.get(quality_value, str(quality_value)) + sleep_lines.append(f" Sleep Quality: {quality_value} ({quality_text})") + + if entries.get("sleepScore") is not None: + sleep_lines.append(f" Device Sleep Score: {entries['sleepScore']}/100") + + if entries.get("readiness") is not None: + sleep_lines.append(f" Readiness: {entries['readiness']}/10") + + return sleep_lines + + +def _format_menstrual_tracking(entries: dict[str, Any]) -> list[str]: + """Format menstrual tracking section.""" + menstrual_lines = [] + if entries.get("menstrualPhase") is not None: + menstrual_lines.append(f" Menstrual Phase: {str(entries['menstrualPhase']).capitalize()}") + if entries.get("menstrualPhasePredicted") is not None: + menstrual_lines.append( + f" Predicted Phase: {str(entries['menstrualPhasePredicted']).capitalize()}" + ) + return menstrual_lines + + +def _format_subjective_feelings(entries: dict[str, Any]) -> list[str]: + """Format subjective feelings section.""" + subjective_lines = [] + for k, label in [ + ("soreness", "Soreness"), + ("fatigue", "Fatigue"), + ("stress", "Stress"), + ("mood", "Mood"), + ("motivation", "Motivation"), + ("injury", "Injury Level"), + ]: + if entries.get(k) is not None: + subjective_lines.append(f" {label}: {entries[k]}/10") + return subjective_lines + + +def _format_nutrition_hydration(entries: dict[str, Any]) -> list[str]: + """Format nutrition and hydration section. + + Handles both legacy fields (kcalConsumed, hydrationVolume) and the native + macro fields from the Intervals.icu API (carbohydrates, protein, + fatTotal). All fields are rendered conditionally — a null/missing value + hides the corresponding line for backward compatibility with older + wellness records. + """ + nutrition_lines = [] + for k, label, unit in [ + ("kcalConsumed", "Calories Consumed", ""), + ("carbohydrates", "Carbohydrates", "g"), + ("protein", "Protein", "g"), + ("fatTotal", "Fat", "g"), + ("hydrationVolume", "Hydration Volume", ""), + ]: + if entries.get(k) is not None: + suffix = f" {unit}" if unit else "" + nutrition_lines.append(f"- {label}: {entries[k]}{suffix}") + + if entries.get("hydration") is not None: + nutrition_lines.append(f" Hydration Score: {entries['hydration']}/10") + + return nutrition_lines + + +def _format_other_fields(entries: dict[str, Any], known_keys: set[str]) -> list[str]: + """Format any fields not already handled by the standard formatting sections.""" + other_lines = [] + for key, value in entries.items(): + if key not in known_keys and value is not None: + if isinstance(value, (dict, list)): + other_lines.append(f"- {key}: {json.dumps(value)}") + else: + other_lines.append(f"- {key}: {value}") + return other_lines + + +def format_wellness_entry(entries: dict[str, Any], include_all_fields: bool = False) -> str: + """Format wellness entry data into a readable string. + + Formats various wellness metrics including training metrics, vital signs, + sleep data, menstrual tracking, subjective feelings, nutrition, and activity. + + Args: + entries: Dictionary containing wellness data fields such as: + - Training metrics: ctl, atl, rampRate, ctlLoad, atlLoad + - Vital signs: weight, restingHR, hrv, hrvSDNN, avgSleepingHR, spO2, + systolic, diastolic, respiration, bloodGlucose, lactate, vo2max, + bodyFat, abdomen, baevskySI + - Sleep: sleepSecs, sleepHours, sleepQuality, sleepScore, readiness + - Menstrual: menstrualPhase, menstrualPhasePredicted + - Subjective: soreness, fatigue, stress, mood, motivation, injury + - Nutrition: kcalConsumed, carbohydrates, protein, fatTotal, hydrationVolume, hydration + - Activity: steps + - Other: comments, locked, date + include_all_fields: If True, any fields not covered by the standard + sections are appended under an "Other Fields" heading (default False). + + Returns: + A formatted string representation of the wellness entry. + """ + if include_all_fields: + entries = _KeyTracker(entries) + # Mark metadata/internal keys so they don't appear in "Other Fields" + entries.get("date") + entries.get("updated") + entries.get("tempWeight") + entries.get("tempRestingHR") + + lines = ["Wellness Data:"] + lines.append(f"Date: {entries.get('id', 'N/A')}") + lines.append("") + + training_metrics = _format_training_metrics(entries) + if training_metrics: + lines.append("Training Metrics:") + lines.extend(training_metrics) + lines.append("") + + sport_info_list = _format_sport_info(entries) + if sport_info_list: + lines.append("Sport-Specific Info:") + lines.extend(sport_info_list) + lines.append("") + + vital_signs = _format_vital_signs(entries) + if vital_signs: + lines.append("Vital Signs:") + lines.extend(vital_signs) + lines.append("") + + sleep_lines = _format_sleep_recovery(entries) + if sleep_lines: + lines.append("Sleep & Recovery:") + lines.extend(sleep_lines) + lines.append("") + + menstrual_lines = _format_menstrual_tracking(entries) + if menstrual_lines: + lines.append("Menstrual Tracking:") + lines.extend(menstrual_lines) + lines.append("") + + subjective_lines = _format_subjective_feelings(entries) + if subjective_lines: + lines.append("Subjective Feelings:") + lines.extend(subjective_lines) + lines.append("") + + nutrition_lines = _format_nutrition_hydration(entries) + if nutrition_lines: + lines.append("Nutrition & Hydration:") + lines.extend(nutrition_lines) + lines.append("") + + if entries.get("steps") is not None: + lines.append("Activity:") + lines.append(f"- Steps: {entries['steps']}") + lines.append("") + + if entries.get("comments"): + lines.append(f"Comments: {entries['comments']}") + if "locked" in entries: + lines.append(f"Status: {'Locked' if entries.get('locked') else 'Unlocked'}") + + if include_all_fields and isinstance(entries, _KeyTracker): + other_lines = _format_other_fields(entries, entries.accessed) + if other_lines: + lines.append("") + lines.append("Other Fields:") + lines.extend(other_lines) + + return "\n".join(lines) + + +def format_event_summary(event: dict[str, Any]) -> str: + """Format a basic event summary into a readable string.""" + + # Update to check for "date" if "start_date_local" is not provided + event_date = event.get("start_date_local", event.get("date", "Unknown")) + event_type = "Workout" if event.get("workout") else "Race" if event.get("race") else "Other" + event_name = event.get("name", "Unnamed") + event_id = event.get("id", "N/A") + event_desc = event.get("description", "No description") + + return f"""Date: {event_date} +ID: {event_id} +Type: {event_type} +Name: {event_name} +Description: {event_desc}""" + + +def format_event_details(event: dict[str, Any]) -> str: + """Format detailed event information into a readable string.""" + + event_details = f"""Event Details: + +ID: {event.get("id", "N/A")} +Date: {event.get("date", "Unknown")} +Name: {event.get("name", "Unnamed")} +Description: {event.get("description", "No description")}""" + + # Check if it's a workout-based event + if "workout" in event and event["workout"]: + workout = event["workout"] + event_details += f""" + +Workout Information: +Workout ID: {workout.get("id", "N/A")} +Sport: {workout.get("sport", "Unknown")} +Duration: {workout.get("duration", 0)} seconds +TSS: {workout.get("tss", "N/A")}""" + + # Include interval count if available + if "intervals" in workout and isinstance(workout["intervals"], list): + event_details += f""" +Intervals: {len(workout["intervals"])}""" + + # Check if it's a race + if event.get("race"): + event_details += f""" + +Race Information: +Priority: {event.get("priority", "N/A")} +Result: {event.get("result", "N/A")}""" + + # Include calendar information + if "calendar" in event: + cal = event["calendar"] + event_details += f""" + +Calendar: {cal.get("name", "N/A")}""" + + return event_details + + +def format_activity_message(message: dict[str, Any]) -> str: + """Format an activity message/note into a readable string.""" + created = message.get("created", "Unknown") + if isinstance(created, str) and len(created) > 10: + try: + dt = datetime.fromisoformat(created.replace("Z", "+00:00")) + created = dt.strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + pass + + return f"""Author: {message.get("name", "Unknown")} +Date: {created} +Type: {message.get("type", "TEXT")} +Content: {message.get("content", "")}""" + + +def format_custom_item_details(item: dict[str, Any]) -> str: + """Format detailed custom item information into a readable string.""" + lines = ["Custom Item Details:", ""] + lines.append(f"ID: {item.get('id', 'N/A')}") + lines.append(f"Name: {item.get('name', 'N/A')}") + lines.append(f"Type: {item.get('type', 'N/A')}") + + if item.get("description"): + lines.append(f"Description: {item['description']}") + if item.get("visibility"): + lines.append(f"Visibility: {item['visibility']}") + if item.get("index") is not None: + lines.append(f"Index: {item['index']}") + if item.get("hide_script") is not None: + lines.append(f"Hide Script: {item['hide_script']}") + if item.get("content"): + lines.append(f"Content: {json.dumps(item['content'], indent=2)}") + + return "\n".join(lines) + + +def format_intervals(intervals_data: dict[str, Any]) -> str: + """Format intervals data into a readable string with all available fields. + + Args: + intervals_data: The intervals data from the Intervals.icu API + + Returns: + A formatted string representation of the intervals data + """ + # Format basic intervals information + result = f"""Intervals Analysis: + +ID: {intervals_data.get("id", "N/A")} +Analyzed: {intervals_data.get("analyzed", "N/A")} + +""" + + # Format individual intervals + if "icu_intervals" in intervals_data and intervals_data["icu_intervals"]: + result += "Individual Intervals:\n\n" + + for i, interval in enumerate(intervals_data["icu_intervals"], 1): + result += f"""[{i}] {interval.get("label", f"Interval {i}")} ({interval.get("type", "Unknown")}) +Duration: {interval.get("elapsed_time", 0)} seconds (moving: {interval.get("moving_time", 0)} seconds) +Distance: {interval.get("distance", 0)} meters +Start-End Indices: {interval.get("start_index", 0)}-{interval.get("end_index", 0)} + +Power Metrics: + Average Power: {interval.get("average_watts", 0)} watts ({interval.get("average_watts_kg", 0)} W/kg) + Max Power: {interval.get("max_watts", 0)} watts ({interval.get("max_watts_kg", 0)} W/kg) + Weighted Avg Power: {interval.get("weighted_average_watts", 0)} watts + Intensity: {interval.get("intensity", 0)} + Training Load: {interval.get("training_load", 0)} + Joules: {interval.get("joules", 0)} + Joules > FTP: {interval.get("joules_above_ftp", 0)} + Power Zone: {interval.get("zone", "N/A")} ({interval.get("zone_min_watts", 0)}-{interval.get("zone_max_watts", 0)} watts) + W' Balance: Start {interval.get("wbal_start", 0)}, End {interval.get("wbal_end", 0)} + L/R Balance: {interval.get("avg_lr_balance", 0)} + Variability: {interval.get("w5s_variability", 0)} + Torque: Avg {interval.get("average_torque", 0)}, Min {interval.get("min_torque", 0)}, Max {interval.get("max_torque", 0)} + +Heart Rate & Metabolic: + Heart Rate: Avg {interval.get("average_heartrate", 0)}, Min {interval.get("min_heartrate", 0)}, Max {interval.get("max_heartrate", 0)} bpm + Decoupling: {interval.get("decoupling", 0)} + DFA α1: {interval.get("average_dfa_a1", 0)} + Respiration: {interval.get("average_respiration", 0)} breaths/min + EPOC: {interval.get("average_epoc", 0)} + SmO2: {interval.get("average_smo2", 0)}% / {interval.get("average_smo2_2", 0)}% + THb: {interval.get("average_thb", 0)} / {interval.get("average_thb_2", 0)} + +Speed & Cadence: + Speed: Avg {interval.get("average_speed", 0)}, Min {interval.get("min_speed", 0)}, Max {interval.get("max_speed", 0)} m/s + GAP: {interval.get("gap", 0)} m/s + Cadence: Avg {interval.get("average_cadence", 0)}, Min {interval.get("min_cadence", 0)}, Max {interval.get("max_cadence", 0)} rpm + Stride: {interval.get("average_stride", 0)} + +Elevation & Environment: + Elevation Gain: {interval.get("total_elevation_gain", 0)} meters + Altitude: Min {interval.get("min_altitude", 0)}, Max {interval.get("max_altitude", 0)} meters + Gradient: {interval.get("average_gradient", 0)}% + Temperature: {interval.get("average_temp", 0)}°C (Weather: {interval.get("average_weather_temp", 0)}°C, Feels like: {interval.get("average_feels_like", 0)}°C) + Wind: Speed {interval.get("average_wind_speed", 0)} km/h, Gust {interval.get("average_wind_gust", 0)} km/h, Direction {interval.get("prevailing_wind_deg", 0)}° + Headwind: {interval.get("headwind_percent", 0)}%, Tailwind: {interval.get("tailwind_percent", 0)}% + +""" + + # Format interval groups + if "icu_groups" in intervals_data and intervals_data["icu_groups"]: + result += "Interval Groups:\n\n" + + for i, group in enumerate(intervals_data["icu_groups"], 1): + result += f"""Group: {group.get("id", f"Group {i}")} (Contains {group.get("count", 0)} intervals) +Duration: {group.get("elapsed_time", 0)} seconds (moving: {group.get("moving_time", 0)} seconds) +Distance: {group.get("distance", 0)} meters +Start-End Indices: {group.get("start_index", 0)}-N/A + +Power: Avg {group.get("average_watts", 0)} watts ({group.get("average_watts_kg", 0)} W/kg), Max {group.get("max_watts", 0)} watts +W. Avg Power: {group.get("weighted_average_watts", 0)} watts, Intensity: {group.get("intensity", 0)} +Heart Rate: Avg {group.get("average_heartrate", 0)}, Max {group.get("max_heartrate", 0)} bpm +Speed: Avg {group.get("average_speed", 0)}, Max {group.get("max_speed", 0)} m/s +Cadence: Avg {group.get("average_cadence", 0)}, Max {group.get("max_cadence", 0)} rpm + +""" + + return result + + +def _format_duration_label(secs: int) -> str: + """Format seconds into a concise human-readable label (e.g. 5s, 2m, 1h).""" + if secs < 60: + return f"{secs}s" + if secs < 3600: + mins = secs // 60 + remainder = secs % 60 + if remainder: + return f"{mins}m{remainder}s" + return f"{mins}m" + hours = secs // 3600 + remainder = (secs % 3600) // 60 + if remainder: + return f"{hours}h{remainder}m" + return f"{hours}h" + + +def format_power_curves( + curves: list[dict[str, Any]], + activity_type: str, + include_normalised: bool, +) -> str: + """Format extracted power curve data into a concise readable string. + + Args: + curves: List of extracted curve data dicts with id, label, data_points. + activity_type: The activity type used for the query. + include_normalised: Whether W/kg data is included. + + Returns: + A formatted string representation of the power curves. + """ + lines: list[str] = [f"Power Curves ({activity_type}):", ""] + + for curve in curves: + label = curve.get("label", curve.get("id", "Unknown")) + start = curve.get("start", "") + end = curve.get("end", "") + date_range = "" + if start and end: + # Trim time portion if present + start_short = start[:10] if len(start) > 10 else start + end_short = end[:10] if len(end) > 10 else end + date_range = f" ({start_short} to {end_short})" + + lines.append(f"{label}{date_range}:") + + data_points = curve.get("data_points", []) + if not data_points: + lines.append(" No data available for requested durations.") + lines.append("") + continue + + for point in data_points: + dur_label = _format_duration_label(point["secs"]) + watts = point.get("watts") + aid = point.get("activity_id", "") + parts = [f" {dur_label}: {watts}W"] + if include_normalised and "watts_per_kg" in point: + parts.append(f"{point['watts_per_kg']:.2f}W/kg") + wkg_aid = point.get("wkg_activity_id", "") + if wkg_aid and wkg_aid != aid: + parts.append(f"[{aid}|wkg:{wkg_aid}]") + else: + parts.append(f"[{aid}]") + else: + parts.append(f"[{aid}]") + lines.append(" ".join(parts)) + lines.append("") + + return "\n".join(lines) diff --git a/src/intervals_mcp_server/utils/types.py b/src/intervals_mcp_server/utils/types.py new file mode 100644 index 0000000..e8365f4 --- /dev/null +++ b/src/intervals_mcp_server/utils/types.py @@ -0,0 +1,590 @@ +""" +Type definitions for Intervals.icu MCP Server. + +This module contains dataclasses and enums for representing workout data structures +used in the Intervals.icu API, including workout steps, values, and documentation. +Also includes enums for server configuration. +""" + +from dataclasses import dataclass +from typing import List, Dict, Optional, Any, Union +from enum import Enum, StrEnum +import json + + +__all__ = [ + "Option", + "WorkoutTarget", + "HrTarget", + "Intensity", + "PaceUnits", + "ValueUnits", + "TransportAliases", + "Value", + "Step", + "SportSettings", + "WorkoutDoc", +] + + +class Option(Enum): + """Enumeration of workout option types.""" + + CATEGORY = "category" + POOL_LENGTH = "pool_length" + POWER = "power" + + +class WorkoutTarget(Enum): + """Enumeration of workout target types.""" + + AUTO = "AUTO" + POWER = "POWER" + HR = "HR" + PACE = "PACE" + + +class HrTarget(Enum): + """Enumeration of heart rate target averaging methods.""" + + LAP = "lap" + INSTANT = "1s" + THREE_SECOND = "3s" + TEN_SECOND = "10s" + THIRTY_SECOND = "30s" + + +class Intensity(Enum): + """Enumeration of workout step intensity types.""" + + ACTIVE = "active" + REST = "rest" + WARMUP = "warmup" + COOLDOWN = "cooldown" + RECOVERY = "recovery" + INTERVAL = "interval" + OTHER = "other" + + +class PaceUnits(Enum): + """Enumeration of pace unit types for swimming and running.""" + + SECS_100M = "SECS_100M" + SECS_100Y = "SECS_100Y" + MINS_KM = "MINS_KM" + MINS_MILE = "MINS_MILE" + SECS_500M = "SECS_500M" + + +class ValueUnits(Enum): + """Enumeration of value unit types for workout steps (power, heart rate, pace, cadence).""" + + PERCENT_MMP = "%mmp" + PERCENT_HR = "%hr" + PERCENT_LTHR = "%lthr" + PERCENT_PACE = "%pace" + POWER_ZONE = "power_zone" + HR_ZONE = "hr_zone" + PACE_ZONE = "pace_zone" + WATTS = "w" + PERCENT_FTP = "%ftp" + CADENCE = "cadence" + MINS_KM = "MINS_KM" + MINS_MILE = "MINS_MILE" + SECS_100M = "SECS_100M" + SECS_500M = "SECS_500M" + + +class TransportAliases(StrEnum): + """Enumeration of supported MCP transport types.""" + + STDIO = "stdio" + SSE = "sse" + HTTP = "http" + STREAMABLE_HTTP = "streamable-http" + + +def float_to_str(value: float) -> str: + """Format the value without decimals if it's a whole number.""" + return str(int(value)) if value.is_integer() else str(value) + + +@dataclass +class Value: + """Represents a value with units for workout step intensity (power, heart rate, pace, cadence). + + Can represent a single value, a range (start-end), or a ramp. Supports various unit types + including percentages, zones, and absolute values. + """ + + value: Optional[float] = None + start: Optional[float] = None + end: Optional[float] = None + units: Optional[ValueUnits] = None + target: Optional[HrTarget] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert Value instance to dictionary for JSON serialization.""" + data: Dict[str, Any] = {} + if self.value is not None: + data["value"] = self.value + if self.start is not None: + data["start"] = self.start + if self.end is not None: + data["end"] = self.end + if self.units is not None: + data["units"] = self.units.value + if self.target is not None: + data["target"] = self.target.value + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Value": + """Create Value instance from dictionary.""" + kwargs = {} + if "value" in data: + kwargs["value"] = data["value"] + if "start" in data: + kwargs["start"] = data["start"] + if "end" in data: + kwargs["end"] = data["end"] + if "units" in data: + kwargs["units"] = ValueUnits(data["units"]) + if "target" in data: + kwargs["target"] = HrTarget(data["target"]) + return cls(**kwargs) + + def to_json(self) -> str: + """Convert Value instance to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> "Value": + """Create Value instance from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + def _format_value(self, value: float) -> str: + if self.units in [ + ValueUnits.PERCENT_HR, + ValueUnits.PERCENT_MMP, + ValueUnits.PERCENT_LTHR, + ValueUnits.PERCENT_PACE, + ValueUnits.PERCENT_FTP, + ]: + return f"{float_to_str(value)}%" + if self.units in [ValueUnits.POWER_ZONE, ValueUnits.HR_ZONE, ValueUnits.PACE_ZONE]: + return f"Z{float_to_str(value)}" + if self.units in [ValueUnits.WATTS]: + return f"{float_to_str(value)}W" + if self.units in [ValueUnits.CADENCE]: + return f"{float_to_str(value)}rpm" + return float_to_str(value) + + def _format_units(self) -> str: + """Format units into a human-readable string using dictionary mapping.""" + units_map = { + ValueUnits.PERCENT_HR: "HR", + ValueUnits.HR_ZONE: "HR", + ValueUnits.PERCENT_MMP: "MMP", + ValueUnits.PERCENT_LTHR: "LTHR", + ValueUnits.PERCENT_PACE: "Pace", + ValueUnits.PACE_ZONE: "Pace", + ValueUnits.PERCENT_FTP: "ftp", + ValueUnits.POWER_ZONE: "W", + ValueUnits.CADENCE: "Cadence", + } + if self.units is None: + return "" + return units_map.get(self.units, "") + + def __str__(self) -> str: + val = "" + if self.start is not None and self.end is not None: + val += f"{self._format_value(self.start)}-{self._format_value(self.end)} " + if self.value is not None: + val += f"{self._format_value(self.value)} " + if self.units is not None: + val += f"{self._format_units()} " + if self.target is not None: + val += f"hr={self.target.value} " + return val.strip() + + +@dataclass +class Step: # pylint: disable=too-many-instance-attributes + """Represents a single step in a workout. + + A step can be a warmup, cooldown, interval, or repeat block. It can specify + duration, distance, intensity targets (power, heart rate, pace, cadence), and + contain nested steps for repeats. + """ + + text: Optional[str] = None + text_locale: Optional[Dict[str, str]] = None + duration: Optional[int] = None + distance: Optional[float] = None + until_lap_press: Optional[bool] = None + reps: Optional[int] = None + warmup: Optional[bool] = None + cooldown: Optional[bool] = None + intensity: Optional[Intensity] = None + steps: Optional[List["Step"]] = None + ramp: Optional[bool] = None + freeride: Optional[bool] = None + maxeffort: Optional[bool] = None + power: Optional[Value] = None + hr: Optional[Value] = None + pace: Optional[Value] = None + cadence: Optional[Value] = None + hidepower: Optional[bool] = None + # these are filled in with actual watts, bpm etc. when resolve=true parameter is supplied to the endpoint + _power: Optional[Value] = None + _hr: Optional[Value] = None + _pace: Optional[Value] = None + _distance: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: # pylint: disable=too-many-branches + """Convert Step instance to dictionary for JSON serialization. + + Many branches are required to handle all optional fields of the Step dataclass. + """ + data: Dict[str, Any] = {} + if self.text is not None: + data["text"] = self.text + if self.text_locale is not None: + data["text_locale"] = self.text_locale + if self.duration is not None: + data["duration"] = self.duration + if self.distance is not None: + data["distance"] = self.distance + if self.until_lap_press is not None: + data["until_lap_press"] = self.until_lap_press + if self.reps is not None: + data["reps"] = self.reps + if self.warmup is not None: + data["warmup"] = self.warmup + if self.cooldown is not None: + data["cooldown"] = self.cooldown + if self.intensity is not None: + data["intensity"] = self.intensity.value + if self.steps is not None: + data["steps"] = [step.to_dict() for step in self.steps] + if self.ramp is not None: + data["ramp"] = self.ramp + if self.freeride is not None: + data["freeride"] = self.freeride + if self.maxeffort is not None: + data["maxeffort"] = self.maxeffort + if self.power is not None: + data["power"] = self.power.to_dict() + if self.hr is not None: + data["hr"] = self.hr.to_dict() + if self.pace is not None: + data["pace"] = self.pace.to_dict() + if self.cadence is not None: + data["cadence"] = self.cadence.to_dict() + if self.hidepower is not None: + data["hidepower"] = self.hidepower + if self._power is not None: + data["_power"] = self._power.to_dict() + if self._hr is not None: + data["_hr"] = self._hr.to_dict() + if self._pace is not None: + data["_pace"] = self._pace.to_dict() + if self._distance is not None: + data["_distance"] = self._distance + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Step": # pylint: disable=too-many-branches + """Create Step instance from dictionary. + + Many branches are required to handle all optional fields of the Step dataclass. + """ + kwargs = {} + if "text" in data: + kwargs["text"] = data["text"] + if "text_locale" in data: + kwargs["text_locale"] = data["text_locale"] + if "duration" in data: + kwargs["duration"] = data["duration"] + if "distance" in data: + kwargs["distance"] = data["distance"] + if "until_lap_press" in data: + kwargs["until_lap_press"] = data["until_lap_press"] + if "reps" in data: + kwargs["reps"] = data["reps"] + if "warmup" in data: + kwargs["warmup"] = data["warmup"] + if "cooldown" in data: + kwargs["cooldown"] = data["cooldown"] + if "intensity" in data: + kwargs["intensity"] = Intensity(data["intensity"]) + if "steps" in data: + kwargs["steps"] = [cls.from_dict(step) for step in data["steps"]] + if "ramp" in data: + kwargs["ramp"] = data["ramp"] + if "freeride" in data: + kwargs["freeride"] = data["freeride"] + if "maxeffort" in data: + kwargs["maxeffort"] = data["maxeffort"] + if "power" in data: + kwargs["power"] = Value.from_dict(data["power"]) + if "hr" in data: + kwargs["hr"] = Value.from_dict(data["hr"]) + if "pace" in data: + kwargs["pace"] = Value.from_dict(data["pace"]) + if "cadence" in data: + kwargs["cadence"] = Value.from_dict(data["cadence"]) + if "hidepower" in data: + kwargs["hidepower"] = data["hidepower"] + if "_power" in data: + kwargs["_power"] = Value.from_dict(data["_power"]) + if "_hr" in data: + kwargs["_hr"] = Value.from_dict(data["_hr"]) + if "_pace" in data: + kwargs["_pace"] = Value.from_dict(data["_pace"]) + if "_distance" in data: + kwargs["_distance"] = data["_distance"] + return cls(**kwargs) + + def to_json(self) -> str: + """Convert Step instance to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> "Step": + """Create Step instance from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + def _format_duration(self) -> str: + """Format duration into a human-readable string.""" + if self.duration is None: + return "" + remaining_duration = self.duration + val = "" + if remaining_duration > 3600: + val += f"{remaining_duration // 3600}h" + remaining_duration %= 3600 + if remaining_duration > 100 or remaining_duration == 60: + val += f"{remaining_duration // 60}m" + remaining_duration %= 60 + if remaining_duration > 0: + val += f"{remaining_duration}s" + return val + + def _format_distance(self) -> str: + """Format distance into a human-readable string.""" + if self.distance is None: + return "" + if self.distance < 1000: + return f"{float_to_str(self.distance)}mtr" + return f"{float_to_str(self.distance / 1000)}km" + + def __str__(self) -> str: + """Convert Step to string representation.""" + return self._to_str() + + def _to_str(self, nested: bool = False) -> str: # pylint: disable=too-many-branches + """Convert Step to string representation. + + Many branches are required to format all optional fields and handle different step types. + """ + val = "" + if self.reps is not None: + if nested: + raise ValueError("Nested steps not supported") + val += f"\n{self.reps}x " + else: + if not nested and self.warmup: + val += "\nWarmup\n" + if not nested and self.cooldown: + val += "\nCooldown\n" + + val += "" + if self.duration is not None: + val += f"- {self._format_duration()} " + elif self.distance is not None: + val += f"- {self._format_distance()} " + + if self.freeride: + val += "freeride " + if self.maxeffort: + val += "maxeffort " + if self.ramp: + val += "ramp " + if self.hidepower: + val += "hidepower " + if self.intensity is not None: + val += f"intensity={self.intensity.value} " + + if self.power is not None: + val += f"{self.power} " + if self.hr is not None: + val += f"{self.hr} " + if self.pace is not None: + val += f"{self.pace} " + if self.cadence is not None: + val += f"{self.cadence} " + if self.text is not None: + val += f"{self.text} " + if self.reps is not None and self.steps is not None: + for step in self.steps: + # Using _to_str instead of __str__ because we need the nested=True arg; + # __str__ can't accept extra parameters. + val += "\n" + step._to_str(nested=True) # pylint: disable=protected-access + val += "\n" + elif not nested and (self.warmup or self.cooldown): + val += "\n" + return val + + +@dataclass +class SportSettings: + """Represents sport-specific settings for a workout. + + Currently empty, but can be extended with sport-specific configuration + as needed by the Intervals.icu API. + """ + + def to_dict(self) -> Dict[str, Any]: + """Convert SportSettings instance to dictionary for JSON serialization.""" + return {} + + @classmethod + def from_dict(cls, _data: Dict[str, Any]) -> "SportSettings": + """Create SportSettings instance from dictionary.""" + return cls() + + def to_json(self) -> str: + """Convert SportSettings instance to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> "SportSettings": + """Create SportSettings instance from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + +@dataclass +class WorkoutDoc: # pylint: disable=too-many-instance-attributes + """Represents a complete workout document with description, steps, and settings. + + This is the main structure used to define workouts for the Intervals.icu API, + containing workout metadata, step definitions, and sport-specific settings. + + Many instance attributes are required to match the Intervals.icu API schema exactly. + """ + + description: Optional[str] = None + description_locale: Optional[Dict[str, str]] = None + duration: Optional[int] = None + distance: Optional[float] = None + ftp: Optional[int] = None + lthr: Optional[int] = None + threshold_pace: Optional[float] = None # meters/sec + pace_units: Optional[PaceUnits] = None + sport_settings: Optional[SportSettings] = None + category: Optional[str] = None + target: Optional[WorkoutTarget] = None + steps: Optional[List[Step]] = None + zone_times: Optional[List[Union[int, Any]]] = ( + None # sometimes array of ints otherwise array of objects + ) + options: Optional[Dict[str, str]] = None + locales: Optional[List[str]] = None + + def to_dict(self) -> Dict[str, Any]: # pylint: disable=too-many-branches + """Convert WorkoutDoc instance to dictionary for JSON serialization. + + Many branches are required to handle all optional fields of the WorkoutDoc dataclass. + """ + data: Dict[str, Any] = {} + if self.description is not None: + data["description"] = self.description + if self.description_locale is not None: + data["description_locale"] = self.description_locale + if self.duration is not None: + data["duration"] = self.duration + if self.distance is not None: + data["distance"] = self.distance + if self.ftp is not None: + data["ftp"] = self.ftp + if self.lthr is not None: + data["lthr"] = self.lthr + if self.threshold_pace is not None: + data["threshold_pace"] = self.threshold_pace + if self.pace_units is not None: + data["pace_units"] = self.pace_units.value + if self.sport_settings is not None: + data["sportSettings"] = self.sport_settings.to_dict() # API uses camelCase + if self.category is not None: + data["category"] = self.category + if self.target is not None: + data["target"] = self.target.value + if self.steps is not None: + data["steps"] = [step.to_dict() for step in self.steps] + if self.zone_times is not None: + data["zoneTimes"] = self.zone_times # API uses camelCase + if self.options is not None: + data["options"] = self.options + if self.locales is not None: + data["locales"] = self.locales + return data + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "WorkoutDoc": # pylint: disable=too-many-branches + """Create WorkoutDoc instance from dictionary. + + Many branches are required to handle all optional fields of the WorkoutDoc dataclass. + """ + kwargs = {} + if "description" in data: + kwargs["description"] = data["description"] + if "description_locale" in data: + kwargs["description_locale"] = data["description_locale"] + if "duration" in data: + kwargs["duration"] = data["duration"] + if "distance" in data: + kwargs["distance"] = data["distance"] + if "ftp" in data: + kwargs["ftp"] = data["ftp"] + if "lthr" in data: + kwargs["lthr"] = data["lthr"] + if "threshold_pace" in data: + kwargs["threshold_pace"] = data["threshold_pace"] + if "pace_units" in data: + kwargs["pace_units"] = PaceUnits(data["pace_units"]) + if "sportSettings" in data: # API uses camelCase + kwargs["sport_settings"] = SportSettings.from_dict(data["sportSettings"]) + if "category" in data: + kwargs["category"] = data["category"] + if "target" in data: + kwargs["target"] = WorkoutTarget(data["target"]) + if "steps" in data: + kwargs["steps"] = [Step.from_dict(step) for step in data["steps"]] + if "zoneTimes" in data: # API uses camelCase + kwargs["zone_times"] = data["zoneTimes"] + if "options" in data: + kwargs["options"] = data["options"] + if "locales" in data: + kwargs["locales"] = data["locales"] + return cls(**kwargs) + + def to_json(self) -> str: + """Convert WorkoutDoc instance to JSON string.""" + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> "WorkoutDoc": + """Create WorkoutDoc instance from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + def __str__(self) -> str: + val = "" + if self.description is not None: + val += f"{self.description}\n" + if self.steps is not None: + for step in self.steps: + val += step.__str__() + "\n" + return val diff --git a/src/intervals_mcp_server/utils/validation.py b/src/intervals_mcp_server/utils/validation.py new file mode 100644 index 0000000..ce0faa5 --- /dev/null +++ b/src/intervals_mcp_server/utils/validation.py @@ -0,0 +1,118 @@ +""" +Validation utilities for Intervals.icu MCP Server + +This module contains validation functions for input parameters. +""" + +import re +from datetime import datetime + +from intervals_mcp_server.utils.dates import parse_date_range + + +def validate_athlete_id(athlete_id: str) -> None: + """Validate that an athlete ID is in the correct format. + + Empty strings are allowed (meaning no default athlete ID is set). + Non-empty athlete IDs must be all digits or start with 'i' followed by digits. + + Args: + athlete_id: The athlete ID to validate. + + Raises: + ValueError: If the athlete ID is not in the correct format. + """ + if athlete_id and not re.fullmatch(r"i?\d+", athlete_id): + raise ValueError( + "ATHLETE_ID must be all digits (e.g. 123456) or start with 'i' followed by digits (e.g. i123456)" + ) + + +def validate_date(date_str: str) -> str: + """Validate that a date string is in YYYY-MM-DD format. + + Args: + date_str: The date string to validate. + + Returns: + The validated date string if valid. + + Raises: + ValueError: If the date string is not in YYYY-MM-DD format. + """ + try: + datetime.strptime(date_str, "%Y-%m-%d") + return date_str + except ValueError as exc: + raise ValueError("Invalid date format. Please use YYYY-MM-DD.") from exc + + +def resolve_athlete_id( + athlete_id: str | None, default_athlete_id: str = "" +) -> tuple[str, str | None]: + """Resolve athlete ID from parameter or default, with error message if missing. + + Args: + athlete_id: Optional athlete ID parameter. + default_athlete_id: Default athlete ID to use if athlete_id is None. + + Returns: + Tuple of (athlete_id_to_use, error_message). + athlete_id_to_use will be empty string if not found. + error_message will be None if athlete_id is resolved successfully. + """ + athlete_id_to_use = athlete_id if athlete_id is not None else default_athlete_id + if not athlete_id_to_use: + return ( + "", + "Error: No athlete ID provided and no default ATHLETE_ID found in environment variables.", + ) + return athlete_id_to_use, None + + +def resolve_activity_type(name: str | None, activity_type: str | None = None) -> str: + """Determine the activity type based on the name and provided value. + + If an explicit *activity_type* is given it is returned as-is. Otherwise the + *name* is searched for common keywords to infer the type, defaulting to + ``"Ride"`` when no match is found. + + Args: + name: An optional activity/event name to infer the type from. + activity_type: An explicitly provided activity type. + + Returns: + The resolved activity type string. + """ + if activity_type: + return activity_type + name_lower = name.lower() if name else "" + mapping = [ + ("Ride", ["bike", "cycle", "cycling", "ride"]), + ("Run", ["run", "running", "jog", "jogging"]), + ("Swim", ["swim", "swimming", "pool"]), + ("Walk", ["walk", "walking", "hike", "hiking"]), + ("Row", ["row", "rowing"]), + ] + for workout, keywords in mapping: + if any(keyword in name_lower for keyword in keywords): + return workout + return "Ride" # Default + + +def resolve_date_params( + start_date: str | None, + end_date: str | None, + default_start_days_ago: int = 30, +) -> tuple[str, str]: + """Resolve start and end date parameters with defaults. + + Args: + start_date: Optional start date in YYYY-MM-DD format. + end_date: Optional end date in YYYY-MM-DD format. + default_start_days_ago: Number of days ago for default start date. Defaults to 30. + + Returns: + Tuple of (start_date, end_date) as strings in YYYY-MM-DD format. + """ + return parse_date_range(start_date, end_date, default_start_days_ago) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ressources/wellness_entry.json b/tests/ressources/wellness_entry.json new file mode 100644 index 0000000..949f04b --- /dev/null +++ b/tests/ressources/wellness_entry.json @@ -0,0 +1,55 @@ +{ + "id": "2025-05-24", + "ctl": 70.87253, + "atl": 91.97159, + "rampRate": 6.997368, + "ctlLoad": 299, + "atlLoad": 299, + "sportInfo": [ + { + "type": "Ride", + "eftp": 282.85992 + }, + { + "type": "Run", + "eftp": null + } + ], + "updated": "2025-05-25T08:10:00.827+00:00", + "weight": 78, + "restingHR": 50, + "hrv": null, + "hrvSDNN": null, + "menstrualPhase": null, + "menstrualPhasePredicted": null, + "kcalConsumed": null, + "carbohydrates": null, + "protein": null, + "fatTotal": null, + "sleepSecs": null, + "sleepScore": null, + "sleepQuality": null, + "avgSleepingHR": null, + "soreness": null, + "fatigue": null, + "stress": null, + "mood": null, + "motivation": null, + "injury": null, + "spO2": null, + "systolic": null, + "diastolic": null, + "hydration": null, + "hydrationVolume": null, + "readiness": null, + "baevskySI": null, + "bloodGlucose": null, + "lactate": null, + "bodyFat": null, + "abdomen": null, + "vo2max": null, + "comments": null, + "steps": 2303, + "respiration": null, + "locked": null +} \ No newline at end of file diff --git a/tests/ressources/wellness_entry_formatted.txt b/tests/ressources/wellness_entry_formatted.txt new file mode 100644 index 0000000..7570639 --- /dev/null +++ b/tests/ressources/wellness_entry_formatted.txt @@ -0,0 +1,21 @@ +Wellness Data: +Date: 2025-05-24 + +Training Metrics: +- Fitness (CTL): 70.87253 +- Fatigue (ATL): 91.97159 +- Ramp Rate: 6.997368 +- CTL Load: 299 +- ATL Load: 299 + +Sport-Specific Info: +- Ride: eFTP = 282.85992 + +Vital Signs: +- Weight: 78 kg +- Resting HR: 50 bpm + +Activity: +- Steps: 2303 + +Status: Unlocked \ No newline at end of file diff --git a/tests/sample_data.py b/tests/sample_data.py new file mode 100644 index 0000000..8c16144 --- /dev/null +++ b/tests/sample_data.py @@ -0,0 +1,87 @@ +""" +Sample data for testing Intervals.icu MCP server functions. + +This module contains test data structures used across the test suite. +""" + +INTERVALS_DATA = { + "id": "i1", + "analyzed": True, + "icu_intervals": [ + { + "type": "work", + "label": "Rep 1", + "elapsed_time": 60, + "moving_time": 60, + "distance": 100, + "average_watts": 200, + "max_watts": 300, + "average_watts_kg": 3.0, + "max_watts_kg": 5.0, + "weighted_average_watts": 220, + "intensity": 0.8, + "training_load": 10, + "average_heartrate": 150, + "max_heartrate": 160, + "average_cadence": 90, + "max_cadence": 100, + "average_speed": 6, + "max_speed": 8, + } + ], +} + +POWER_CURVES_DATA = { + "list": [ + { + "id": "s0", + "label": "This season", + "start_date_local": "2025-09-29T00:00:00", + "end_date_local": "2026-03-14T00:00:00", + "days": 167, + "weight": 75.0, + "secs": [1, 2, 3, 4, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 3600], + "values": [900, 850, 820, 800, 780, 650, 550, 450, 380, 320, 280, 260, 245, 210], + "activity_id": [ + "i100", "i100", "i100", "i100", "i100", + "i101", "i101", "i101", "i102", + "i103", "i104", "i105", "i106", "i107", + ], + "watts_per_kg": [ + 12.0, 11.33, 10.93, 10.67, 10.4, + 8.67, 7.33, 6.0, 5.07, + 4.27, 3.73, 3.47, 3.27, 2.8, + ], + "wkg_activity_id": [ + "i100", "i100", "i100", "i100", "i100", + "i101", "i101", "i101", "i102", + "i103", "i104", "i105", "i106", "i107", + ], + }, + { + "id": "s1", + "label": "Last season", + "start_date_local": "2024-09-29T00:00:00", + "end_date_local": "2025-09-28T00:00:00", + "days": 365, + "weight": 76.0, + "secs": [1, 2, 3, 4, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 3600], + "values": [870, 830, 800, 770, 750, 630, 520, 430, 360, 300, 265, 250, 235, 200], + "activity_id": [ + "i200", "i200", "i200", "i200", "i200", + "i201", "i201", "i201", "i202", + "i203", "i204", "i205", "i206", "i207", + ], + "watts_per_kg": [ + 11.45, 10.92, 10.53, 10.13, 9.87, + 8.29, 6.84, 5.66, 4.74, + 3.95, 3.49, 3.29, 3.09, 2.63, + ], + "wkg_activity_id": [ + "i200", "i200", "i200", "i200", "i200", + "i201", "i201", "i201", "i202", + "i203", "i204", "i205", "i206", "i207", + ], + }, + ] +} diff --git a/tests/test_formatting.py b/tests/test_formatting.py new file mode 100644 index 0000000..ee846a7 --- /dev/null +++ b/tests/test_formatting.py @@ -0,0 +1,239 @@ +""" +Unit tests for formatting utilities in intervals_mcp_server.utils.formatting. + +These tests verify that the formatting functions produce expected output strings for activities, workouts, wellness entries, events, and intervals. +""" + +import json +from intervals_mcp_server.utils.formatting import ( + format_activity_summary, + format_workout, + format_wellness_entry, + format_event_summary, + format_event_details, + format_intervals, + format_power_curves, +) +from tests.sample_data import INTERVALS_DATA + + +def test_format_activity_summary(): + """ + Test that format_activity_summary returns a string containing the activity name and ID. + """ + data = { + "name": "Morning Ride", + "id": 1, + "type": "Ride", + "startTime": "2024-01-01T08:00:00Z", + "distance": 1000, + "duration": 3600, + } + result = format_activity_summary(data) + assert "Activity: Morning Ride" in result + assert "ID: 1" in result + + +def test_format_workout(): + """ + Test that format_workout returns a string containing the workout name and interval count. + """ + workout = { + "name": "Workout1", + "description": "desc", + "sport": "Ride", + "duration": 3600, + "tss": 50, + "intervals": [1, 2, 3], + } + result = format_workout(workout) + assert "Workout: Workout1" in result + assert "Intervals: 3" in result + + +def test_format_wellness_entry(): + """ + Test that format_wellness_entry returns a string containing the date and fitness (CTL). + """ + with open("tests/ressources/wellness_entry.json", "r", encoding="utf-8") as f: + entry = json.load(f) + result = format_wellness_entry(entry) + + with open("tests/ressources/wellness_entry_formatted.txt", "r", encoding="utf-8") as f: + expected_result = f.read() + assert result == expected_result + + +def test_format_wellness_entry_include_all_fields(): + """ + Test that format_wellness_entry with include_all_fields=True includes additional unknown fields. + """ + entry = { + "id": "2024-06-01", + "ctl": 80, + "weight": 75, + "customField1": "hello", + "customField2": 42, + "updated": "2024-06-01T10:00:00Z", + } + result = format_wellness_entry(entry, include_all_fields=True) + assert "Date: 2024-06-01" in result + assert "Fitness (CTL): 80" in result + assert "Weight: 75 kg" in result + assert "Other Fields:" in result + assert "customField1: hello" in result + assert "customField2: 42" in result + # "updated" is a known built-in field, should not appear in Other Fields + assert "updated:" not in result + + +def test_format_wellness_entry_no_extra_fields_by_default(): + """ + Test that format_wellness_entry without include_all_fields does not include additional fields. + """ + entry = { + "id": "2024-06-01", + "ctl": 80, + "customField1": "hello", + } + result = format_wellness_entry(entry) + assert "Other Fields:" not in result + assert "customField1" not in result + + +def test_format_wellness_entry_macros_populated(): + """ + Test that format_wellness_entry renders native nutrition macros + (carbohydrates, protein, fatTotal) in grams when present. + """ + entry = { + "id": "2026-04-08", + "carbohydrates": 310, + "protein": 145, + "fatTotal": 72, + } + result = format_wellness_entry(entry) + assert "Nutrition & Hydration:" in result + assert "- Carbohydrates: 310 g" in result + assert "- Protein: 145 g" in result + assert "- Fat: 72 g" in result + + +def test_format_wellness_entry_macros_null_hidden(): + """ + Test that format_wellness_entry hides macro lines when the fields are null, + preserving backward compatibility with older wellness records. + """ + entry = { + "id": "2026-04-08", + "ctl": 80, + "carbohydrates": None, + "protein": None, + "fatTotal": None, + } + result = format_wellness_entry(entry) + assert "Carbohydrates" not in result + assert "Protein" not in result + # "Fat" could legitimately appear inside e.g. "Body Fat" elsewhere, so + # anchor the negative assertion on the line-prefix form we would emit. + assert "- Fat:" not in result + + +def test_format_event_summary(): + """ + Test that format_event_summary returns a string containing the event date and type. + """ + event = { + "start_date_local": "2024-01-01", + "id": "e1", + "name": "Event1", + "description": "desc", + "race": True, + } + summary = format_event_summary(event) + assert "Date: 2024-01-01" in summary + assert "Type: Race" in summary + + +def test_format_event_details(): + """ + Test that format_event_details returns a string containing event and workout details. + """ + event = { + "id": "e1", + "date": "2024-01-01", + "name": "Event1", + "description": "desc", + "workout": { + "id": "w1", + "sport": "Ride", + "duration": 3600, + "tss": 50, + "intervals": [1, 2], + }, + "race": True, + "priority": "A", + "result": "1st", + "calendar": {"name": "Main"}, + } + details = format_event_details(event) + assert "Event Details:" in details + assert "Workout Information:" in details + + +def test_format_intervals(): + """ + Test that format_intervals returns a string containing interval analysis and the interval label. + """ + result = format_intervals(INTERVALS_DATA) + assert "Intervals Analysis:" in result + assert "Rep 1" in result + + +def test_format_power_curves(): + """ + Test that format_power_curves returns a concise string with curve labels, + power values, W/kg values, and activity IDs. + """ + curves = [ + { + "id": "s0", + "label": "This season", + "start": "2025-09-29T00:00:00", + "end": "2026-03-14T00:00:00", + "data_points": [ + {"secs": 5, "watts": 780, "activity_id": "i100", "watts_per_kg": 10.4, "wkg_activity_id": "i100"}, + {"secs": 60, "watts": 380, "activity_id": "i102", "watts_per_kg": 5.07, "wkg_activity_id": "i102"}, + {"secs": 3600, "watts": 210, "activity_id": "i107", "watts_per_kg": 2.8, "wkg_activity_id": "i107"}, + ], + }, + ] + result = format_power_curves(curves, "Ride", include_normalised=True) + assert "Power Curves (Ride):" in result + assert "This season" in result + assert "5s: 780W" in result + assert "10.40W/kg" in result + assert "1m: 380W" in result + assert "1h: 210W" in result + assert "i100" in result + assert "i107" in result + + +def test_format_power_curves_without_normalised(): + """ + Test that format_power_curves without normalised data does not include W/kg. + """ + curves = [ + { + "id": "s0", + "label": "This season", + "start": "", + "end": "", + "data_points": [ + {"secs": 5, "watts": 780, "activity_id": "i100"}, + ], + }, + ] + result = format_power_curves(curves, "Ride", include_normalised=False) + assert "780W" in result + assert "W/kg" not in result diff --git a/tests/test_make_intervals_request.py b/tests/test_make_intervals_request.py new file mode 100644 index 0000000..525f05b --- /dev/null +++ b/tests/test_make_intervals_request.py @@ -0,0 +1,101 @@ +""" +Unit tests for the make_intervals_request function in intervals_mcp_server.server. + +These tests focus on error handling, particularly the scenario where the API returns invalid JSON. +Mock classes are used to simulate httpx responses and client behavior. +""" + +import asyncio +import logging +import os +import pathlib +import sys +from json import JSONDecodeError + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src")) +os.environ.setdefault("API_KEY", "test") +os.environ.setdefault("ATHLETE_ID", "i1") + +from intervals_mcp_server import server # pylint: disable=wrong-import-position +from intervals_mcp_server.api import client as api_client # pylint: disable=wrong-import-position +from intervals_mcp_server.config import Config # pylint: disable=wrong-import-position + + +class MockBadJSONResponse: + """ + Simulates an httpx response object that returns invalid JSON content. + Used to test error handling for JSONDecodeError in make_intervals_request. + """ + + def __init__(self): + self.content = b"bad" + self.status_code = 200 + + def raise_for_status(self): + """Mock raise_for_status that does nothing.""" + return None + + def json(self): + """Raise JSONDecodeError to simulate invalid JSON.""" + raise JSONDecodeError("Expecting value", "bad", 0) + + +class MockAsyncClient: + """ + Simulates an httpx.AsyncClient for use in monkeypatching. + Always returns a MockBadJSONResponse from get(). + """ + + def __init__(self, *_args, **_kwargs): + # Accept any arguments to match httpx.AsyncClient's interface + self.is_closed = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + async def get(self, _url, **_kwargs): + """Mock get method that returns MockBadJSONResponse.""" + return MockBadJSONResponse() + + async def request(self, *_args, **_kwargs): + """Mock request method that returns MockBadJSONResponse.""" + return MockBadJSONResponse() + + async def aclose(self): + """Simulate closing the AsyncClient.""" + self.is_closed = True + + +def test_make_intervals_request_bad_json(monkeypatch, caplog): + """ + Test that make_intervals_request returns an error dict when the response contains invalid JSON. + Ensures proper logging and error message content. + """ + monkeypatch.setenv("API_KEY", "test") + monkeypatch.setenv("ATHLETE_ID", "i1") + # Reset the singleton so config picks up the monkeypatched env vars + monkeypatch.setattr("intervals_mcp_server.config._config_instance", None) + monkeypatch.setattr(server, "httpx_client", MockAsyncClient()) + monkeypatch.setattr( + api_client, + "get_config", + lambda: Config( + api_key="test", + athlete_id="i1", + intervals_api_base_url="https://intervals.icu/api/v1", + user_agent="test-agent", + ), + ) + + # Ensure the config singleton has an API key, regardless of test execution order + from intervals_mcp_server.config import get_config # pylint: disable=import-outside-toplevel + monkeypatch.setattr(get_config(), "api_key", "test") + + with caplog.at_level(logging.ERROR): + result = asyncio.run(server.make_intervals_request("/bad")) + + assert result["error"] is True + assert "Invalid JSON in response" in result["message"] diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..8cfb6e9 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,951 @@ +""" +Unit tests for the main MCP server tool functions in intervals_mcp_server.server. + +These tests use monkeypatching to mock API responses and verify the formatting and output of each tool function: +- get_activities +- get_activity_details +- get_activity_intervals +- get_activity_streams +- get_activity_messages +- add_activity_message +- get_events +- get_event_by_id +- add_or_update_event +- get_wellness_data + +The tests ensure that the server's public API returns expected strings and handles data correctly. +""" + +import asyncio +import os +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src")) +os.environ.setdefault("API_KEY", "test") +os.environ.setdefault("ATHLETE_ID", "i1") + +from intervals_mcp_server.server import ( # pylint: disable=wrong-import-position + add_activity_message, + get_activities, + get_activity_details, + get_activity_intervals, + get_activity_messages, + get_activity_streams, + add_or_update_event, + get_athlete_power_curves, + get_event_by_id, + get_events, + get_gear_list, + get_wellness_data, + get_custom_items, + get_custom_item_by_id, + create_custom_item, + update_custom_item, + delete_custom_item, +) +from intervals_mcp_server.tools import gear as gear_module # pylint: disable=wrong-import-position +from tests.sample_data import INTERVALS_DATA, POWER_CURVES_DATA # pylint: disable=wrong-import-position + + +def _reset_gear_cache(): + """Helper to clear the module-level gear cache between tests.""" + gear_module._GEAR_RAW_CACHE.clear() # pylint: disable=protected-access + + +def test_get_activities(monkeypatch): + """ + Test get_activities returns a formatted string containing activity details when given a sample activity. + """ + sample = { + "name": "Morning Ride", + "id": 123, + "type": "Ride", + "startTime": "2024-01-01T08:00:00Z", + "distance": 1000, + "duration": 3600, + } + + async def fake_request(*_args, **_kwargs): + return [sample] + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(get_activities(athlete_id="1", limit=1, include_unnamed=True)) + assert "Morning Ride" in result + assert "Activities:" in result + + +def test_get_activity_details(monkeypatch): + """ + Test get_activity_details returns a formatted string with the activity name and details. + """ + sample = { + "name": "Morning Ride", + "id": 123, + "type": "Ride", + "startTime": "2024-01-01T08:00:00Z", + "distance": 1000, + "duration": 3600, + } + + async def fake_request(*_args, **_kwargs): + return sample + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(get_activity_details(123)) + assert "Activity: Morning Ride" in result + + +def test_get_events(monkeypatch): + """ + Test get_events returns a formatted string containing event details when given a sample event. + """ + event = { + "date": "2024-01-01", + "id": "e1", + "name": "Test Event", + "description": "desc", + "race": True, + } + + async def fake_request(*_args, **_kwargs): + return [event] + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request) + result = asyncio.run(get_events(athlete_id="1", start_date="2024-01-01", end_date="2024-01-02")) + assert "Test Event" in result + assert "Events:" in result + + +def test_get_event_by_id(monkeypatch): + """ + Test get_event_by_id returns a formatted string with event details for a given event ID. + """ + event = { + "id": "e1", + "date": "2024-01-01", + "name": "Test Event", + "description": "desc", + "race": True, + } + + async def fake_request(*_args, **_kwargs): + return event + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr("intervals_mcp_server.tools.events.make_intervals_request", fake_request) + result = asyncio.run(get_event_by_id("e1", athlete_id="1")) + assert "Event Details:" in result + assert "Test Event" in result + + +def test_get_wellness_data(monkeypatch): + """ + Test get_wellness_data returns a formatted string containing wellness data for a given athlete. + """ + wellness = { + "2024-01-01": { + "id": "2024-01-01", + "ctl": 75, + "sleepSecs": 28800, + } + } + + async def fake_request(*_args, **_kwargs): + return wellness + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request) + result = asyncio.run(get_wellness_data(athlete_id="1")) + assert "Wellness Data:" in result + assert "2024-01-01" in result + + +def test_get_wellness_data_renders_macros(monkeypatch): + """ + Integration test: native nutrition macros (carbohydrates, protein, + fatTotal) flow from the API response through get_wellness_data into the + formatted output. + """ + wellness = [ + { + "id": "2026-04-08", + "carbohydrates": 310, + "protein": 145, + "fatTotal": 72, + } + ] + + async def fake_request(*_args, **_kwargs): + return wellness + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request) + result = asyncio.run(get_wellness_data(athlete_id="1")) + assert "Wellness Data:" in result + assert "2026-04-08" in result + assert "Nutrition & Hydration:" in result + assert "- Carbohydrates: 310 g" in result + assert "- Protein: 145 g" in result + assert "- Fat: 72 g" in result + + +def test_get_wellness_data_include_all_fields(monkeypatch): + """ + Test get_wellness_data with include_all_fields=True returns a formatted string including additional fields. + """ + wellness = [ + { + "id": "2024-01-01", + "ctl": 75, + "sleepSecs": 28800, + "customField": "custom_value", + } + ] + + async def fake_request(*_args, **_kwargs): + return wellness + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr("intervals_mcp_server.tools.wellness.make_intervals_request", fake_request) + result = asyncio.run(get_wellness_data(athlete_id="1", include_all_fields=True)) + assert "Wellness Data:" in result + assert "2024-01-01" in result + assert "Fitness (CTL): 75" in result + assert "Other Fields:" in result + assert "customField: custom_value" in result + + +def test_get_activity_intervals(monkeypatch): + """ + Test get_activity_intervals returns a formatted string with interval analysis for a given activity. + """ + + async def fake_request(*_args, **_kwargs): + return INTERVALS_DATA + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(get_activity_intervals("123")) + assert "Intervals Analysis:" in result + assert "Rep 1" in result + + +def test_get_activity_streams(monkeypatch): + """ + Test get_activity_streams returns a formatted string with stream data for a given activity. + """ + sample_streams = [ + { + "type": "time", + "name": "time", + "data": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "data2": [], + "valueType": "time_units", + "valueTypeIsArray": False, + "anomalies": None, + "custom": False, + }, + { + "type": "watts", + "name": "watts", + "data": [150, 155, 160, 165, 170, 175, 180, 185, 190, 195, 200], + "data2": [], + "valueType": "power_units", + "valueTypeIsArray": False, + "anomalies": None, + "custom": False, + }, + { + "type": "heartrate", + "name": "heartrate", + "data": [120, 125, 130, 135, 140, 145, 150, 155, 160, 165, 170], + "data2": [], + "valueType": "hr_units", + "valueTypeIsArray": False, + "anomalies": None, + "custom": False, + }, + ] + + async def fake_request(*_args, **_kwargs): + return sample_streams + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(get_activity_streams("i107537962")) + assert "Activity Streams" in result + assert "time" in result + assert "watts" in result + assert "heartrate" in result + assert "Data Points: 11" in result + + +def test_add_or_update_event(monkeypatch): + """ + Test add_or_update_event successfully posts an event and returns the response data. + """ + expected_response = { + "id": "e123", + "start_date_local": "2024-01-15T00:00:00", + "category": "WORKOUT", + "name": "Test Workout", + "type": "Ride", + } + + async def fake_post_request(*_args, **_kwargs): + return expected_response + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_post_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.events.make_intervals_request", fake_post_request + ) + result = asyncio.run( + add_or_update_event( + athlete_id="i1", start_date="2024-01-15", name="Test Workout", workout_type="Ride" + ) + ) + assert "Successfully created event id:" in result + assert "e123" in result + + +def test_get_activity_messages(monkeypatch): + """Test get_activity_messages returns formatted messages for an activity.""" + sample_messages = [ + { + "id": 1, + "name": "Niko", + "created": "2024-06-15T10:30:00Z", + "type": "NOTE", + "content": "Legs felt heavy today", + }, + { + "id": 2, + "name": "Coach", + "created": "2024-06-15T11:00:00Z", + "type": "TEXT", + "content": "Good effort despite that!", + }, + ] + + async def fake_request(*_args, **_kwargs): + return sample_messages + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(get_activity_messages(activity_id="i123")) + assert "Legs felt heavy today" in result + assert "Good effort despite that!" in result + assert "Niko" in result + assert "Coach" in result + + +def test_get_activity_messages_error(monkeypatch): + """Test get_activity_messages handles API errors gracefully.""" + + async def fake_request(*_args, **_kwargs): + return {"error": True, "message": "Activity not found"} + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(get_activity_messages(activity_id="i999")) + assert "Error fetching activity messages" in result + assert "Activity not found" in result + + +def test_get_activity_messages_empty(monkeypatch): + """Test get_activity_messages returns appropriate message when no messages exist.""" + + async def fake_request(*_args, **_kwargs): + return [] + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(get_activity_messages(activity_id="i123")) + assert "No messages found" in result + + +def test_add_activity_message(monkeypatch): + """Test add_activity_message posts a message and returns confirmation.""" + + async def fake_request(*_args, **kwargs): + assert kwargs.get("method") == "POST" + assert kwargs.get("data") == {"content": "Great run!"} + return {"id": 42, "new_chat": None} + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(add_activity_message(activity_id="i123", content="Great run!")) + assert "Successfully added message" in result + assert "42" in result + + +def test_add_activity_message_missing_id(monkeypatch): + """Test add_activity_message warns when response has no ID.""" + + async def fake_request(*_args, **_kwargs): + return {"new_chat": None} + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(add_activity_message(activity_id="i123", content="Hello")) + assert "appears to have been added" in result + assert "verify manually" in result + + +def test_add_activity_message_unexpected_response(monkeypatch): + """Test add_activity_message handles unexpected non-dict response.""" + + async def fake_request(*_args, **_kwargs): + return None + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(add_activity_message(activity_id="i123", content="Hello")) + assert "Unexpected response" in result + + +def test_add_activity_message_error(monkeypatch): + """Test add_activity_message handles API errors.""" + + async def fake_request(*_args, **_kwargs): + return {"error": True, "message": "Not found"} + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + result = asyncio.run(add_activity_message(activity_id="i999", content="Hello")) + assert "Error adding message" in result + + +def test_get_athlete_power_curves(monkeypatch): + """ + Test get_athlete_power_curves returns formatted power curve data with both seasons. + """ + + async def fake_request(*_args, **_kwargs): + return POWER_CURVES_DATA + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request + ) + result = asyncio.run( + get_athlete_power_curves( + activity_type="Ride", + athlete_id="i1", + ) + ) + assert "Power Curves (Ride):" in result + assert "This season" in result + assert "Last season" in result + assert "5s:" in result + assert "W/kg" in result + assert "i100" in result + + +def test_get_athlete_power_curves_custom_durations(monkeypatch): + """ + Test get_athlete_power_curves with custom durations returns only those durations. + """ + + async def fake_request(*_args, **_kwargs): + return POWER_CURVES_DATA + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request + ) + result = asyncio.run( + get_athlete_power_curves( + activity_type="Ride", + durations=[5, 60], + athlete_id="i1", + ) + ) + assert "5s:" in result + assert "1m:" in result + # Should not contain durations we didn't request + assert "15s:" not in result + assert "10m:" not in result + + +def test_get_athlete_power_curves_without_normalised(monkeypatch): + """ + Test get_athlete_power_curves without normalised data excludes W/kg values. + """ + + async def fake_request(*_args, **_kwargs): + return POWER_CURVES_DATA + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request + ) + result = asyncio.run( + get_athlete_power_curves( + activity_type="Ride", + include_normalised=False, + athlete_id="i1", + ) + ) + assert "W/kg" not in result + assert "780W" in result + + +def test_get_athlete_power_curves_date_validation(monkeypatch): + """ + Test get_athlete_power_curves validates date parameters. + """ + + async def fake_request(*_args, **_kwargs): + return POWER_CURVES_DATA + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request + ) + # Only start_date without end_date should fail + result = asyncio.run( + get_athlete_power_curves( + activity_type="Ride", + start_date="2026-01-01", + athlete_id="i1", + ) + ) + assert "Error" in result + assert "start_date and end_date must be provided together" in result + + +def test_get_athlete_power_curves_no_curves_selected(monkeypatch): + """ + Test get_athlete_power_curves returns error when no curves selected. + """ + + async def fake_request(*_args, **_kwargs): + return POWER_CURVES_DATA + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.power_curves.make_intervals_request", fake_request + ) + result = asyncio.run( + get_athlete_power_curves( + activity_type="Ride", + this_season=False, + last_season=False, + athlete_id="i1", + ) + ) + assert "Error" in result + assert "At least one curve must be selected" in result + + +def test_get_custom_items(monkeypatch): + """ + Test get_custom_items returns a formatted string containing custom item details. + """ + custom_items = [ + {"id": 1, "name": "HR Zones", "type": "ZONES", "description": "Heart rate zones"}, + {"id": 2, "name": "Power Chart", "type": "FITNESS_CHART", "description": None}, + ] + + async def fake_request(*_args, **_kwargs): + return custom_items + + # Patch in both api.client and tools modules to ensure it works + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request + ) + result = asyncio.run(get_custom_items(athlete_id="1")) + assert "Custom Items:" in result + assert "HR Zones" in result + assert "ZONES" in result + assert "Power Chart" in result + + +def test_get_custom_item_by_id(monkeypatch): + """ + Test get_custom_item_by_id returns formatted details of a single custom item. + """ + custom_item = { + "id": 1, + "name": "HR Zones", + "type": "ZONES", + "description": "Heart rate zones", + "visibility": "PRIVATE", + "index": 0, + } + + async def fake_request(*_args, **_kwargs): + return custom_item + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request + ) + result = asyncio.run(get_custom_item_by_id(item_id=1, athlete_id="1")) + assert "Custom Item Details:" in result + assert "HR Zones" in result + assert "ZONES" in result + assert "Heart rate zones" in result + assert "PRIVATE" in result + + +def test_create_custom_item(monkeypatch): + """ + Test create_custom_item returns a success message with formatted item details. + """ + created_item = { + "id": 10, + "name": "New Chart", + "type": "FITNESS_CHART", + "description": "A new fitness chart", + "visibility": "PRIVATE", + } + + async def fake_request(*_args, **_kwargs): + return created_item + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request + ) + result = asyncio.run( + create_custom_item(name="New Chart", item_type="FITNESS_CHART", athlete_id="1") + ) + assert "Successfully created custom item:" in result + assert "New Chart" in result + assert "FITNESS_CHART" in result + + +def test_create_custom_item_with_string_content(monkeypatch): + """ + Test create_custom_item correctly parses content when passed as a JSON string. + """ + captured: dict = {} + + async def fake_request(*_args, **kwargs): + captured["data"] = kwargs.get("data") + return { + "id": 11, + "name": "Activity Field", + "type": "ACTIVITY_FIELD", + "content": {"expression": "icu_training_load"}, + } + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request + ) + result = asyncio.run( + create_custom_item( + name="Activity Field", + item_type="ACTIVITY_FIELD", + athlete_id="1", + content='{"expression": "icu_training_load"}', # type: ignore[arg-type] + ) + ) + assert "Successfully created custom item:" in result + # Verify the content was parsed from string to dict before being sent + assert isinstance(captured["data"]["content"], dict) + assert captured["data"]["content"]["expression"] == "icu_training_load" + + +def test_update_custom_item(monkeypatch): + """ + Test update_custom_item returns a success message with formatted item details. + """ + updated_item = { + "id": 1, + "name": "Updated Chart", + "type": "FITNESS_CHART", + "description": "Updated description", + "visibility": "PUBLIC", + } + + async def fake_request(*_args, **_kwargs): + return updated_item + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request + ) + result = asyncio.run( + update_custom_item(item_id=1, name="Updated Chart", athlete_id="1") + ) + assert "Successfully updated custom item:" in result + assert "Updated Chart" in result + assert "PUBLIC" in result + + +def test_delete_custom_item(monkeypatch): + """ + Test delete_custom_item returns the API response. + """ + + async def fake_request(*_args, **_kwargs): + return {} + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request + ) + result = asyncio.run(delete_custom_item(item_id=1, athlete_id="1")) + assert "Successfully deleted" in result + + +def test_create_custom_item_with_invalid_json_content(monkeypatch): + """ + Test create_custom_item returns an error message when content is an invalid JSON string. + """ + + async def fake_request(*_args, **_kwargs): + return {} + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.custom_items.make_intervals_request", fake_request + ) + result = asyncio.run( + create_custom_item( + name="Bad Item", + item_type="FITNESS_CHART", + athlete_id="1", + content="not valid json", # type: ignore[arg-type] + ) + ) + assert "Error: content must be valid JSON when passed as a string." in result + + +# --------------------------------------------------------------------------- +# Gear tools +# --------------------------------------------------------------------------- + + +def test_get_gear_list(monkeypatch): + """ + Test get_gear_list returns a formatted catalog with id, type, name and stats. + """ + _reset_gear_cache() + + sample_gear = [ + { + "id": "b1", + "type": "Bike", + "name": "Litening Air", + "default_for_type": "Ride", + "activities": 100, + "distance": 4_155_700, + "retired": False, + }, + { + "id": "b2", + "type": "Bike", + "name": "Retired bike", + "activities": 50, + "distance": 2_000_000, + "retired": True, + }, + ] + + async def fake_request(*_args, **_kwargs): + return sample_gear + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.gear.make_intervals_request", fake_request + ) + + result = asyncio.run(get_gear_list(athlete_id="i1")) + + assert "Gear catalog for athlete i1:" in result + assert "Litening Air" in result + assert "b1" in result + assert "Retired bike" in result + assert "yes" in result # retired flag rendered + assert "Ride" in result # default_for_type rendered + + +def test_get_gear_list_empty(monkeypatch): + """ + Test get_gear_list returns an informative message when no gear is configured. + """ + _reset_gear_cache() + + async def fake_request(*_args, **_kwargs): + return [] + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.gear.make_intervals_request", fake_request + ) + + result = asyncio.run(get_gear_list(athlete_id="i1")) + assert "No gear found" in result + + +def test_get_gear_list_cache_and_refresh(monkeypatch): + """ + Test that get_gear_list caches the catalog and that refresh=True busts the cache. + """ + _reset_gear_cache() + + call_count = {"n": 0} + sample_gear = [ + { + "id": "b1", + "type": "Bike", + "name": "Litening Air", + "activities": 100, + "distance": 4_155_700, + } + ] + + async def fake_request(*_args, **_kwargs): + call_count["n"] += 1 + return sample_gear + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.gear.make_intervals_request", fake_request + ) + + # First call: cache cold, one API hit expected. + asyncio.run(get_gear_list(athlete_id="i1")) + assert call_count["n"] == 1 + + # Second call: cache warm, no additional API hit. + asyncio.run(get_gear_list(athlete_id="i1")) + assert call_count["n"] == 1 + + # refresh=True busts the cache and triggers a fresh fetch. + asyncio.run(get_gear_list(athlete_id="i1", refresh=True)) + assert call_count["n"] == 2 + + +def test_get_activity_details_resolves_gear_name(monkeypatch): + """ + Test get_activity_details injects the resolved gear name into the formatted output + when the activity payload contains a gear_id. + """ + _reset_gear_cache() + + activity = { + "name": "Morning Ride", + "id": 123, + "type": "Ride", + "startTime": "2024-01-01T08:00:00Z", + "distance": 1000, + "duration": 3600, + "gear_id": "b1", + } + gear_catalog = [{"id": "b1", "type": "Bike", "name": "Litening Air"}] + + async def fake_request(url=None, **_kwargs): + # The activity endpoint and the gear endpoint share the same fake + # request; route by URL pattern. + if url and "/gear" in url: + return gear_catalog + return activity + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + monkeypatch.setattr( + "intervals_mcp_server.tools.gear.make_intervals_request", fake_request + ) + # get_activity_details does not accept athlete_id; gear resolution falls + # back to the configured ATHLETE_ID, which is unset under CI. Provide one. + monkeypatch.setattr(gear_module.config, "athlete_id", "1") + + result = asyncio.run(get_activity_details(123)) + assert "Activity: Morning Ride" in result + assert "Gear:" in result + assert "Name: Litening Air" in result + assert "ID: b1" in result + + +def test_get_activities_resolves_gear_name(monkeypatch): + """ + Test get_activities injects resolved gear names for each activity in the list. + """ + _reset_gear_cache() + + activities = [ + { + "name": "Ride 1", + "id": 1, + "type": "Ride", + "startTime": "2024-01-01T08:00:00Z", + "distance": 1000, + "duration": 3600, + "gear_id": "b1", + }, + { + "name": "Ride 2", + "id": 2, + "type": "Ride", + "startTime": "2024-01-02T08:00:00Z", + "distance": 2000, + "duration": 5400, + "gear_id": "b2", + }, + ] + gear_catalog = [ + {"id": "b1", "type": "Bike", "name": "Litening Air"}, + {"id": "b2", "type": "Bike", "name": "S-Works Tarmac SL8"}, + ] + + async def fake_request(url=None, **_kwargs): + if url and "/gear" in url: + return gear_catalog + return activities + + monkeypatch.setattr("intervals_mcp_server.api.client.make_intervals_request", fake_request) + monkeypatch.setattr( + "intervals_mcp_server.tools.activities.make_intervals_request", fake_request + ) + monkeypatch.setattr( + "intervals_mcp_server.tools.gear.make_intervals_request", fake_request + ) + + result = asyncio.run(get_activities(athlete_id="1", limit=2, include_unnamed=True)) + assert "Ride 1" in result + assert "Ride 2" in result + assert "Name: Litening Air" in result + assert "Name: S-Works Tarmac SL8" in result diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..04f74f1 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,54 @@ +""" +Unit tests for resolve_activity_type in intervals_mcp_server.utils.validation. +""" + +from intervals_mcp_server.utils.validation import resolve_activity_type + + +def test_explicit_activity_type_returned_as_is(): + """Explicit activity_type is returned unchanged.""" + assert resolve_activity_type(None, "VirtualRide") == "VirtualRide" + assert resolve_activity_type("morning swim", "Run") == "Run" + + +def test_keyword_ride(): + """Cycling keywords resolve to Ride.""" + for name in ["Morning Ride", "cycling session", "bike workout", "cycle"]: + assert resolve_activity_type(name) == "Ride" + + +def test_keyword_run(): + """Running keywords resolve to Run.""" + for name in ["Easy Run", "jogging", "morning jog", "running"]: + assert resolve_activity_type(name) == "Run" + + +def test_keyword_swim(): + """Swimming keywords resolve to Swim.""" + for name in ["Pool Swim", "swimming drills", "swim"]: + assert resolve_activity_type(name) == "Swim" + + +def test_keyword_walk(): + """Walking keywords resolve to Walk.""" + for name in ["Evening Walk", "hiking trip", "hike", "walking"]: + assert resolve_activity_type(name) == "Walk" + + +def test_keyword_row(): + """Rowing keywords resolve to Row.""" + for name in ["Rowing session", "morning row"]: + assert resolve_activity_type(name) == "Row" + + +def test_default_ride_when_no_match(): + """Defaults to Ride when no keyword matches.""" + assert resolve_activity_type("stretching") == "Ride" + assert resolve_activity_type(None) == "Ride" + assert resolve_activity_type("") == "Ride" + + +def test_case_insensitive(): + """Keyword matching is case-insensitive.""" + assert resolve_activity_type("MORNING RUN") == "Run" + assert resolve_activity_type("SWIM") == "Swim" diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 0000000..948d6bd --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,38 @@ +""" +Unit tests for the Value dataclass in intervals_mcp_server.utils.types. + +These tests verify that the Value dataclass correctly handles: +- String formatting for percent FTP units +- Ramp intervals (start/end values) +- Deserialisation of pace/swim-pace unit strings returned by the Intervals.icu API +""" + +import pytest + +from intervals_mcp_server.utils.types import Value, ValueUnits + + +def test_str_percent_ftp(): + """Test formatting percentage FTP values.""" + val = Value(value=95.0, units=ValueUnits.PERCENT_FTP) + assert str(val) == "95% ftp" + + +def test_str_ramp_percent_ftp(): + """Test formatting ramp intervals with percentage FTP.""" + val = Value(start=65, end=85, units=ValueUnits.PERCENT_FTP) + assert str(val) == "65%-85% ftp" + + +@pytest.mark.parametrize("unit_str,expected_enum", [ + ("MINS_KM", ValueUnits.MINS_KM), + ("MINS_MILE", ValueUnits.MINS_MILE), + ("SECS_100M", ValueUnits.SECS_100M), + ("SECS_500M", ValueUnits.SECS_500M), +]) +def test_pace_units_deserialise_from_api_string(unit_str, expected_enum): + """Pace/swim-pace unit strings returned by the Intervals.icu API must round-trip + through Value.from_dict without raising ValueError. This test would fail if any + of these unit strings were missing from the ValueUnits enum.""" + val = Value.from_dict({"value": 5.0, "units": unit_str}) + assert val.units == expected_enum diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d88a526 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1283 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[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.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[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 = "hatch" +version = "1.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "hatchling" }, + { name = "httpx" }, + { name = "hyperlink" }, + { name = "keyring" }, + { name = "packaging" }, + { name = "pexpect" }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "tomli-w" }, + { name = "tomlkit" }, + { name = "userpath" }, + { name = "uv" }, + { name = "virtualenv" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/30/a7f19d337df93fb15dec6892e9ae678acd4ae10ce03d02722f17c7fe513b/hatch-1.15.1.tar.gz", hash = "sha256:444a78123c9837e8c9f5adfbf2b8b0a72139587eb49d6b368038b0521136fc43", size = 5189156, upload-time = "2025-10-16T20:35:54.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/01/316ef533114e0de0649e2e925ae2f97dfe26fbe5358f678e84b2a5fa1407/hatch-1.15.1-py3-none-any.whl", hash = "sha256:99dccb26b00226056142f89d6e286be61e2d7b5b5b4e6178ebbe9298c1bc45d9", size = 126295, upload-time = "2025-10-16T20:35:52.354Z" }, +] + +[[package]] +name = "hatchling" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/8a/cc1debe3514da292094f1c3a700e4ca25442489731ef7c0814358816bb03/hatchling-1.27.0.tar.gz", hash = "sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6", size = 54983, upload-time = "2024-12-15T17:08:11.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794, upload-time = "2024-12-15T17:08:10.364Z" }, +] + +[[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 = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[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 = "intervalsicu-mcp" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dotenv" }, +] + +[package.optional-dependencies] +dev = [ + { name = "hatch" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "hatch", marker = "extra == 'dev'" }, + { name = "httpx", specifier = ">=0.25.0" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.28.1" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = "==3.12.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, +] +provides-extras = ["dev"] + +[[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.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" }, +] + +[[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 = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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 = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[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 = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[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.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[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.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +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/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.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/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.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/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/96/25588c55fbe330b751bd7c7d723c3544957566bc090f6d506551b514f488/pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9", size = 32139, upload-time = "2023-10-19T16:25:57.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/25/b29fd10dd062cf41e66787a7951b3842881a2a2d7e3a41fcbb58a8466046/pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f", size = 9771, upload-time = "2023-10-19T16:25:55.764Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[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/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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/50/bc0e6e736d94e420df79be4deb5c9476b63165c87bb8f19ef75d100d21b3/rpds_py-0.29.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0891cfd8db43e085c0ab93ab7e9b0c8fee84780d436d3b266b113e51e79f954", size = 376000, upload-time = "2025-11-16T14:48:19.141Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/46676277160f014ae95f24de53bed0e3b7ea66c235e7de0b9df7bd5d68ba/rpds_py-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3897924d3f9a0361472d884051f9a2460358f9a45b1d85a39a158d2f8f1ad71c", size = 360575, upload-time = "2025-11-16T14:48:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/411d414ed99ea1afdd185bbabeeaac00624bd1e4b22840b5e9967ade6337/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21deb8e0d1571508c6491ce5ea5e25669b1dd4adf1c9d64b6314842f708b5d", size = 392159, upload-time = "2025-11-16T14:48:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b1/e18aa3a331f705467a48d0296778dc1fea9d7f6cf675bd261f9a846c7e90/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9efe71687d6427737a0a2de9ca1c0a216510e6cd08925c44162be23ed7bed2d5", size = 410602, upload-time = "2025-11-16T14:48:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/04f27f0c9f2299274c76612ac9d2c36c5048bb2c6c2e52c38c60bf3868d9/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40f65470919dc189c833e86b2c4bd21bd355f98436a2cef9e0a9a92aebc8e57e", size = 515808, upload-time = "2025-11-16T14:48:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/83/56/a8412aa464fb151f8bc0d91fb0bb888adc9039bd41c1c6ba8d94990d8cf8/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:def48ff59f181130f1a2cb7c517d16328efac3ec03951cca40c1dc2049747e83", size = 416015, upload-time = "2025-11-16T14:48:26.782Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/f9b8a05faca3d9e0a6397c90d13acb9307c9792b2bff621430c58b1d6e76/rpds_py-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7bd570be92695d89285a4b373006930715b78d96449f686af422debb4d3949", size = 395325, upload-time = "2025-11-16T14:48:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/34/60/869f3bfbf8ed7b54f1ad9a5543e0fdffdd40b5a8f587fe300ee7b4f19340/rpds_py-0.29.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:5a572911cd053137bbff8e3a52d31c5d2dba51d3a67ad902629c70185f3f2181", size = 410160, upload-time = "2025-11-16T14:48:29.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/aa/e5b496334e3aba4fe4c8a80187b89f3c1294c5c36f2a926da74338fa5a73/rpds_py-0.29.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d583d4403bcbf10cffc3ab5cee23d7643fcc960dff85973fd3c2d6c86e8dbb0c", size = 425309, upload-time = "2025-11-16T14:48:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/85/68/4e24a34189751ceb6d66b28f18159922828dd84155876551f7ca5b25f14f/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:070befbb868f257d24c3bb350dbd6e2f645e83731f31264b19d7231dd5c396c7", size = 574644, upload-time = "2025-11-16T14:48:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/474a005ea4ea9c3b4f17b6108b6b13cebfc98ebaff11d6e1b193204b3a93/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fc935f6b20b0c9f919a8ff024739174522abd331978f750a74bb68abd117bd19", size = 601605, upload-time = "2025-11-16T14:48:33.252Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b1/c56f6a9ab8c5f6bb5c65c4b5f8229167a3a525245b0773f2c0896686b64e/rpds_py-0.29.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c5a8ecaa44ce2d8d9d20a68a2483a74c07f05d72e94a4dff88906c8807e77b0", size = 564593, upload-time = "2025-11-16T14:48:34.643Z" }, + { url = "https://files.pythonhosted.org/packages/b3/13/0494cecce4848f68501e0a229432620b4b57022388b071eeff95f3e1e75b/rpds_py-0.29.0-cp312-cp312-win32.whl", hash = "sha256:ba5e1aeaf8dd6d8f6caba1f5539cddda87d511331714b7b5fc908b6cfc3636b7", size = 223853, upload-time = "2025-11-16T14:48:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6a/51e9aeb444a00cdc520b032a28b07e5f8dc7bc328b57760c53e7f96997b4/rpds_py-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:b5f6134faf54b3cb83375db0f113506f8b7770785be1f95a631e7e2892101977", size = 239895, upload-time = "2025-11-16T14:48:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d4/8bce56cdad1ab873e3f27cb31c6a51d8f384d66b022b820525b879f8bed1/rpds_py-0.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:b016eddf00dca7944721bf0cd85b6af7f6c4efaf83ee0b37c4133bd39757a8c7", size = 230321, upload-time = "2025-11-16T14:48:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/c5de60d9d371bbb186c3e9bf75f4fc5665e11117a25a06a6b2e0afb7380e/rpds_py-0.29.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1585648d0760b88292eecab5181f5651111a69d90eff35d6b78aa32998886a61", size = 375710, upload-time = "2025-11-16T14:48:41.063Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b3/0860cdd012291dc21272895ce107f1e98e335509ba986dd83d72658b82b9/rpds_py-0.29.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:521807963971a23996ddaf764c682b3e46459b3c58ccd79fefbe16718db43154", size = 360582, upload-time = "2025-11-16T14:48:42.423Z" }, + { url = "https://files.pythonhosted.org/packages/92/8a/a18c2f4a61b3407e56175f6aab6deacdf9d360191a3d6f38566e1eaf7266/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8896986efaa243ab713c69e6491a4138410f0fe36f2f4c71e18bd5501e8014", size = 391172, upload-time = "2025-11-16T14:48:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/e93354258508c50abc15cdcd5fcf7ac4117f67bb6233ad7859f75e7372a0/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d24564a700ef41480a984c5ebed62b74e6ce5860429b98b1fede76049e953e6", size = 409586, upload-time = "2025-11-16T14:48:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8d/a27860dae1c19a6bdc901f90c81f0d581df1943355802961a57cdb5b6cd1/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6596b93c010d386ae46c9fba9bfc9fc5965fa8228edeac51576299182c2e31c", size = 516339, upload-time = "2025-11-16T14:48:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/a75e603161e79b7110c647163d130872b271c6b28712c803c65d492100f7/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5cc58aac218826d054c7da7f95821eba94125d88be673ff44267bb89d12a5866", size = 416201, upload-time = "2025-11-16T14:48:48.615Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/555b4ee17508beafac135c8b450816ace5a96194ce97fefc49d58e5652ea/rpds_py-0.29.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de73e40ebc04dd5d9556f50180395322193a78ec247e637e741c1b954810f295", size = 395095, upload-time = "2025-11-16T14:48:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f0/c90b671b9031e800ec45112be42ea9f027f94f9ac25faaac8770596a16a1/rpds_py-0.29.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:295ce5ac7f0cf69a651ea75c8f76d02a31f98e5698e82a50a5f4d4982fbbae3b", size = 410077, upload-time = "2025-11-16T14:48:51.515Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/9af8b640b81fe21e6f718e9dec36c0b5f670332747243130a5490f292245/rpds_py-0.29.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ea59b23ea931d494459c8338056fe7d93458c0bf3ecc061cd03916505369d55", size = 424548, upload-time = "2025-11-16T14:48:53.237Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0b/b5647446e991736e6a495ef510e6710df91e880575a586e763baeb0aa770/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f49d41559cebd608042fdcf54ba597a4a7555b49ad5c1c0c03e0af82692661cd", size = 573661, upload-time = "2025-11-16T14:48:54.769Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/1b1c9576839ff583d1428efbf59f9ee70498d8ce6c0b328ac02f1e470879/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:05a2bd42768ea988294ca328206efbcc66e220d2d9b7836ee5712c07ad6340ea", size = 600937, upload-time = "2025-11-16T14:48:56.247Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/b6cfca2f9fee4c4494ce54f7fb1b9f578867495a9aa9fc0d44f5f735c8e0/rpds_py-0.29.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33ca7bdfedd83339ca55da3a5e1527ee5870d4b8369456b5777b197756f3ca22", size = 564496, upload-time = "2025-11-16T14:48:57.691Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/ba29ec7f0f06eb801bac5a23057a9ff7670623b5e8013bd59bec4aa09de8/rpds_py-0.29.0-cp313-cp313-win32.whl", hash = "sha256:20c51ae86a0bb9accc9ad4e6cdeec58d5ebb7f1b09dd4466331fc65e1766aae7", size = 223126, upload-time = "2025-11-16T14:48:59.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6b/0229d3bed4ddaa409e6d90b0ae967ed4380e4bdd0dad6e59b92c17d42457/rpds_py-0.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:6410e66f02803600edb0b1889541f4b5cc298a5ccda0ad789cc50ef23b54813e", size = 239771, upload-time = "2025-11-16T14:49:00.872Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/d2868f058b164f8efd89754d85d7b1c08b454f5c07ac2e6cc2e9bd4bd05b/rpds_py-0.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:56838e1cd9174dc23c5691ee29f1d1be9eab357f27efef6bded1328b23e1ced2", size = 229994, upload-time = "2025-11-16T14:49:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/52/91/5de91c5ec7d41759beec9b251630824dbb8e32d20c3756da1a9a9d309709/rpds_py-0.29.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:37d94eadf764d16b9a04307f2ab1d7af6dc28774bbe0535c9323101e14877b4c", size = 365886, upload-time = "2025-11-16T14:49:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/85/7c/415d8c1b016d5f47ecec5145d9d6d21002d39dce8761b30f6c88810b455a/rpds_py-0.29.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d472cf73efe5726a067dce63eebe8215b14beabea7c12606fd9994267b3cfe2b", size = 355262, upload-time = "2025-11-16T14:49:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/bf83e2daa4f980e4dc848aed9299792a8b84af95e12541d9e7562f84a6ef/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72fdfd5ff8992e4636621826371e3ac5f3e3b8323e9d0e48378e9c13c3dac9d0", size = 384826, upload-time = "2025-11-16T14:49:07.301Z" }, + { url = "https://files.pythonhosted.org/packages/33/b8/53330c50a810ae22b4fbba5e6cf961b68b9d72d9bd6780a7c0a79b070857/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2549d833abdf8275c901313b9e8ff8fba57e50f6a495035a2a4e30621a2f7cc4", size = 394234, upload-time = "2025-11-16T14:49:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/cc/32/01e2e9645cef0e584f518cfde4567563e57db2257244632b603f61b40e50/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4448dad428f28a6a767c3e3b80cde3446a22a0efbddaa2360f4bb4dc836d0688", size = 520008, upload-time = "2025-11-16T14:49:10.253Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/0d1b95a81affae2b10f950782e33a1fd2edd6ce2a479966cac98c9a66f57/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:115f48170fd4296a33938d8c11f697f5f26e0472e43d28f35624764173a60e4d", size = 409569, upload-time = "2025-11-16T14:49:12.478Z" }, + { url = "https://files.pythonhosted.org/packages/fa/60/aa3b8678f3f009f675b99174fa2754302a7fbfe749162e8043d111de2d88/rpds_py-0.29.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5bb73ffc029820f4348e9b66b3027493ae00bca6629129cd433fd7a76308ee", size = 385188, upload-time = "2025-11-16T14:49:13.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/5546c1c8aa89c18d40c1fcffdcc957ba730dee53fb7c3ca3a46f114761d2/rpds_py-0.29.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b1581fcde18fcdf42ea2403a16a6b646f8eb1e58d7f90a0ce693da441f76942e", size = 398587, upload-time = "2025-11-16T14:49:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e0/ad6eeaf47e236eba052fa34c4073078b9e092bd44da6bbb35aaae9580669/rpds_py-0.29.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16e9da2bda9eb17ea318b4c335ec9ac1818e88922cbe03a5743ea0da9ecf74fb", size = 416641, upload-time = "2025-11-16T14:49:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/1a/93/0acedfd50ad9cdd3879c615a6dc8c5f1ce78d2fdf8b87727468bb5bb4077/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:28fd300326dd21198f311534bdb6d7e989dd09b3418b3a91d54a0f384c700967", size = 566683, upload-time = "2025-11-16T14:49:18.342Z" }, + { url = "https://files.pythonhosted.org/packages/62/53/8c64e0f340a9e801459fc6456821abc15b3582cb5dc3932d48705a9d9ac7/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2aba991e041d031c7939e1358f583ae405a7bf04804ca806b97a5c0e0af1ea5e", size = 592730, upload-time = "2025-11-16T14:49:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/85/ef/3109b6584f8c4b0d2490747c916df833c127ecfa82be04d9a40a376f2090/rpds_py-0.29.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f437026dbbc3f08c99cc41a5b2570c6e1a1ddbe48ab19a9b814254128d4ea7a", size = 557361, upload-time = "2025-11-16T14:49:21.574Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3b/61586475e82d57f01da2c16edb9115a618afe00ce86fe1b58936880b15af/rpds_py-0.29.0-cp313-cp313t-win32.whl", hash = "sha256:6e97846e9800a5d0fe7be4d008f0c93d0feeb2700da7b1f7528dabafb31dfadb", size = 211227, upload-time = "2025-11-16T14:49:23.03Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" }, + { url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, + { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, +] +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 = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2025.11.14.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/a9/880cccf76af9e7b322112f52e4e2dbb3534cbe671197b8f443a42189dfc7/trove_classifiers-2025.11.14.15.tar.gz", hash = "sha256:6b60f49d40bbd895bc61d8dc414fc2f2286d70eb72ed23548db8cf94f62804ca", size = 16995, upload-time = "2025-11-14T15:23:13.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/f6/73c4aa003d1237ee9bea8a46f49dc38c45dfe95af4f0da7e60678d388011/trove_classifiers-2025.11.14.15-py3-none-any.whl", hash = "sha256:d1dac259c1e908939862e3331177931c6df0a37af2c1a8debcc603d9115fcdd9", size = 14191, upload-time = "2025-11-14T15:23:12.467Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "userpath" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140, upload-time = "2024-02-29T21:39:08.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065, upload-time = "2024-02-29T21:39:07.551Z" }, +] + +[[package]] +name = "uv" +version = "0.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/08/3bf76403ea7c22feef634849137fab10b28ab5ba5bbf08a53390763d5448/uv-0.9.11.tar.gz", hash = "sha256:605a7a57f508aabd029fc0c5ef5c60a556f8c50d32e194f1a300a9f4e87f18d4", size = 3744387, upload-time = "2025-11-20T23:20:00.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/26/8f917e9faddd9cb49abcbc8c7dac5343b0f61d04c6ac36873d2a324fee1a/uv-0.9.11-py3-none-linux_armv6l.whl", hash = "sha256:803f85cf25ab7f1fca10fe2e40a1b9f5b1d48efc25efd6651ba3c9668db6a19e", size = 20787588, upload-time = "2025-11-20T23:18:53.738Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1f/eafd39c719ddee19fc25884f68c1a7e736c0fca63c1cbef925caf8ebd739/uv-0.9.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6a31b0bd4eaec59bf97816aefbcd75cae4fcc8875c4b19ef1846b7bff3d67c70", size = 19922144, upload-time = "2025-11-20T23:18:57.569Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f3/6b9fac39e5b65fa47dba872dcf171f1470490cd645343e8334f20f73885b/uv-0.9.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48548a23fb5a103b8955dfafff7d79d21112b8e25ce5ff25e3468dc541b20e83", size = 18380643, upload-time = "2025-11-20T23:19:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9a/d4080e95950a4fc6fdf20d67b9a43ffb8e3d6d6b7c8dda460ae73ddbecd9/uv-0.9.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:cb680948e678590b5960744af2ecea6f2c0307dbb74ac44daf5c00e84ad8c09f", size = 20310262, upload-time = "2025-11-20T23:19:04.914Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/86d9c881bd6accf2b766f7193b50e9d5815f2b34806191d90ea24967965e/uv-0.9.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ef1982295e5aaf909a9668d6fb6abfc5089666c699f585a36f3a67f1a22916a", size = 20392988, upload-time = "2025-11-20T23:19:08.258Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/6a227b7ca1829442c1419ba1db856d176b6e0861f9bf9355a8790a5d02b5/uv-0.9.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92ff773aa4193148019533c55382c2f9c661824bbf0c2e03f12aeefc800ede57", size = 21394892, upload-time = "2025-11-20T23:19:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8f/df45b8409923121de8c4081c9d6d8ba3273eaa450645e1e542d83179c7b5/uv-0.9.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70137a46675bbecf3a8b43d292a61767f1b944156af3d0f8d5986292bd86f6cf", size = 22987735, upload-time = "2025-11-20T23:19:16.27Z" }, + { url = "https://files.pythonhosted.org/packages/89/51/bbf3248a619c9f502d310a11362da5ed72c312d354fb8f9667c5aa3be9dd/uv-0.9.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5af9117bab6c4b3a1cacb0cddfb3cd540d0adfb13c7b8a9a318873cf2d07e52", size = 22617321, upload-time = "2025-11-20T23:19:20.1Z" }, + { url = "https://files.pythonhosted.org/packages/3f/cd/a158ec989c5433dc86ebd9fea800f2aed24255b84ab65b6d7407251e5e31/uv-0.9.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8cc86940d9b3a425575f25dc45247be2fb31f7fed7bf3394ae9daadd466e5b80", size = 21615712, upload-time = "2025-11-20T23:19:23.71Z" }, + { url = "https://files.pythonhosted.org/packages/73/da/2597becbc0fcbb59608d38fda5db79969e76dedf5b072f0e8564c8f0628b/uv-0.9.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e97906ca1b90dac91c23af20e282e2e37c8eb80c3721898733928a295f2defda", size = 21661022, upload-time = "2025-11-20T23:19:27.385Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/9b8f3b3529b23c2a6f5b9612da70ea53117935ec999757b4f1d640f63d63/uv-0.9.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d901269e1db72abc974ba61d37be6e56532e104922329e0b553d9df07ba224be", size = 20440548, upload-time = "2025-11-20T23:19:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/72/b2/683afdb83e96dd966eb7cf3688af56a1b826c8bc1e8182fb10ec35b3e391/uv-0.9.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8abfb7d4b136de3e92dd239ea9a51d4b7bbb970dc1b33bec84d08facf82b9a6e", size = 21493758, upload-time = "2025-11-20T23:19:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/f4/00/99848bc9834aab104fa74aa1a60b1ca478dee824d2e4aacb15af85673572/uv-0.9.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:1f8afc13b3b94bce1e72514c598d41623387b2b61b68d7dbce9a01a0d8874860", size = 20332324, upload-time = "2025-11-20T23:19:38.376Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/8cfd1bb1cc5d768cb334f976ba2686c6327e4ac91c16b8469b284956d4d9/uv-0.9.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7d414cfa410f1850a244d87255f98d06ca61cc13d82f6413c4f03e9e0c9effc7", size = 20845062, upload-time = "2025-11-20T23:19:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/a0/42/43f66bfc621464dabe9cfe3cbf69cddc36464da56ab786c94fc9ccf99cc7/uv-0.9.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:edc14143d0ba086a7da4b737a77746bb36bc00e3d26466f180ea99e3bf795171", size = 21857559, upload-time = "2025-11-20T23:19:46.026Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/bfd41bf087522601c724d712c3727aeb62f51b1f67c4ab86a078c3947525/uv-0.9.11-py3-none-win32.whl", hash = "sha256:af5fd91eecaa04b4799f553c726307200f45da844d5c7c5880d64db4debdd5dc", size = 19639246, upload-time = "2025-11-20T23:19:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2f/d51c02627de68a7ca5b82f0a5d61d753beee3fe696366d1a1c5d5e40cd58/uv-0.9.11-py3-none-win_amd64.whl", hash = "sha256:c65a024ad98547e32168f3a52360fe73ff39cd609a8fb9dd2509aac91483cfc8", size = 21626822, upload-time = "2025-11-20T23:19:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/af/d8/e07e866ee328d3c9f27a6d57a018d8330f47be95ef4654a178779c968a66/uv-0.9.11-py3-none-win_arm64.whl", hash = "sha256:4907a696c745703542ed2559bdf5380b92c8b1d4bf290ebfed45bf9a2a2c6690", size = 20046856, upload-time = "2025-11-20T23:19:58.517Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]