From 387445d3cec648d71e0b9737fb843ecef29b8a66 Mon Sep 17 00:00:00 2001 From: himanshu Date: Tue, 12 May 2026 11:44:36 +0530 Subject: [PATCH] refactor: modularize server, add tests, fix docstring/format bugs --- .dockerignore | 43 +- .env.example | 14 + .github/workflows/ci.yml | 33 + .gitignore | 70 +- Dockerfile | 51 +- README.md | 217 +++-- pyproject.toml | 104 ++- requirements.txt | 36 - src/newsdata_mcp/__init__.py | 10 + src/newsdata_mcp/_mcp.py | 84 ++ src/newsdata_mcp/app.py | 22 - src/newsdata_mcp/client.py | 614 -------------- src/newsdata_mcp/config.py | 380 --------- src/newsdata_mcp/formatters.py | 251 ++++++ src/newsdata_mcp/http.py | 358 ++++++++ src/newsdata_mcp/params.py | 459 ++++++++++ src/newsdata_mcp/server.py | 31 +- src/newsdata_mcp/settings.py | 54 ++ src/newsdata_mcp/tools/__init__.py | 29 + src/newsdata_mcp/tools/archive.py | 149 ++++ src/newsdata_mcp/tools/count.py | 144 ++++ src/newsdata_mcp/tools/crypto.py | 115 +++ src/newsdata_mcp/tools/crypto_count.py | 109 +++ src/newsdata_mcp/tools/latest.py | 149 ++++ src/newsdata_mcp/tools/market.py | 146 ++++ src/newsdata_mcp/tools/market_count.py | 137 +++ src/newsdata_mcp/tools/sources.py | 56 ++ src/newsdata_mcp/validators.py | 75 ++ tests/__init__.py | 0 tests/conftest.py | 41 + tests/test_formatters.py | 367 ++++++++ tests/test_http.py | 596 +++++++++++++ tests/test_integration.py | 107 +++ tests/test_tools.py | 687 +++++++++++++++ tests/test_validators.py | 228 +++++ uv.lock | 1072 ++++++++++++++++++++++++ 36 files changed, 5799 insertions(+), 1239 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml delete mode 100644 requirements.txt create mode 100644 src/newsdata_mcp/_mcp.py delete mode 100644 src/newsdata_mcp/app.py delete mode 100644 src/newsdata_mcp/client.py delete mode 100644 src/newsdata_mcp/config.py create mode 100644 src/newsdata_mcp/formatters.py create mode 100644 src/newsdata_mcp/http.py create mode 100644 src/newsdata_mcp/params.py create mode 100644 src/newsdata_mcp/settings.py create mode 100644 src/newsdata_mcp/tools/__init__.py create mode 100644 src/newsdata_mcp/tools/archive.py create mode 100644 src/newsdata_mcp/tools/count.py create mode 100644 src/newsdata_mcp/tools/crypto.py create mode 100644 src/newsdata_mcp/tools/crypto_count.py create mode 100644 src/newsdata_mcp/tools/latest.py create mode 100644 src/newsdata_mcp/tools/market.py create mode 100644 src/newsdata_mcp/tools/market_count.py create mode 100644 src/newsdata_mcp/tools/sources.py create mode 100644 src/newsdata_mcp/validators.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_formatters.py create mode 100644 tests/test_http.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_tools.py create mode 100644 tests/test_validators.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore index 06a7066..f74a003 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,41 @@ +# Build context: the Dockerfile only COPYs pyproject.toml, uv.lock, +# README.md, src/, and LICENSE. Everything else listed here is +# excluded from the build context so `docker build` uploads less and +# layer hashes stay stable when these change. + +# Python build / runtime artifacts __pycache__ -.env -.git +*.pyc +*.pyo +*.egg-info/ +.venv/ +dist/ +build/ + +# Tooling caches +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ + +# Tests, CI, editor config — not needed inside the image +tests/ +.github/ +.vscode/ +.idea/ + +# Local scratch / per-machine markers +.codex +temp.py +.claude/ + +# VCS / environment +.git/ .gitignore -.venv -LICENSE +.env + +# Project docs not shipped in the image (README.md is needed by +# Dockerfile and is NOT listed here). +CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0dabcff --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# NewsData.io credentials — copy this file to `.env` and fill in. +NEWSDATA_API_KEY="your_newsdata_api_key_here" + +# Optional: request timeout in seconds (default: 30) +# REQUEST_TIMEOUT=30 + +# Optional: override the API base URL (e.g. for staging or a local mock) +# NEWSDATA_BASE_URL=https://newsdata.io/api/1 + +# Optional: retry policy for transient failures (network, 5xx, 429). +# Defaults sleep ~62s total across 5 attempts (2s → 4s → 8s → 16s → 32s, capped at 60s). +# NEWSDATA_MAX_RETRIES=5 +# NEWSDATA_RETRY_BACKOFF=2.0 +# NEWSDATA_RETRY_BACKOFF_MAX=60.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f124aed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies (runtime + dev) + run: uv sync --all-groups --frozen + + - name: Lint (ruff) + run: uv run ruff check src/ tests/ + + - name: Type-check (mypy) + run: uv run mypy + + - name: Test (pytest, unit only by default) + run: uv run pytest --cov=newsdata_mcp --cov-report=term-missing diff --git a/.gitignore b/.gitignore index 4a4965d..a26e3ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,54 +1,26 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore - -# Node artifact files -node_modules/ -dist/ - -# Compiled Java class files -*.class - -# Compiled Python bytecode +# Python build artifacts +__pycache__/ *.py[cod] - -# Log files -*.log - -# Package files -*.jar - -# Maven -target/ +*.egg-info/ +build/ dist/ -# JetBrains IDE -.idea/ - -# Unit test reports -TEST*.xml - -# Generated by MacOS -.DS_Store +# Virtual environment +.venv/ -# Generated by Windows -Thumbs.db - -# Applications -*.app -*.exe -*.war - -# Large media files -*.mp4 -*.tiff -*.avi -*.flv -*.mov -*.wmv +# Secrets .env -.venv -__pycache__ -uv.lock -.vscode \ No newline at end of file + +# Tooling caches (mypy, pytest, ruff, coverage) +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ + +# Local-only files +.codex +temp.py +CLAUDE.md +.vscode/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6c8ae4d..4f7ed15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,57 @@ -FROM python:3.12-slim +# Multistage build: resolve and install with uv against the lockfile in +# the builder, then copy the resulting venv into a minimal runtime stage. + +# ---------- Builder ---------- +FROM python:3.12-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - REQUEST_TIMEOUT=30 + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +# uv is a small Rust binary; pip-install it once in the builder. +RUN pip install --no-cache-dir uv WORKDIR /app -COPY pyproject.toml README.md /app/ +# Install dependencies first (without the project itself) so this layer +# stays cached when only application source changes. LICENSE is needed +# at build time because pyproject.toml declares `license = { file = ... }`. +COPY pyproject.toml uv.lock README.md LICENSE /app/ +RUN uv sync --frozen --no-install-project --no-dev + +# Now copy source and install the project itself into the same venv. COPY src /app/src +RUN uv sync --frozen --no-dev -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir . +# ---------- Runtime ---------- +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + REQUEST_TIMEOUT=30 \ + PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +# Copy the populated venv + project source from the builder. LICENSE +# is already inside /app from the builder stage. +COPY --from=builder /app /app + +# Run as a non-root user. +RUN useradd --create-home --uid 1000 app \ + && chown -R app:app /app +USER app EXPOSE 8000 +# TCP-level liveness probe; only meaningful for streamable-http transport. +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost',8000)); s.close()" || exit 1 + +LABEL org.opencontainers.image.title="newsdata-mcp" \ + org.opencontainers.image.description="MCP server for NewsData.io" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.source="https://github.com/newsdataapi/newsdata.io-mcp" + ENTRYPOINT ["newsdata-mcp"] CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 912d3a1..e10b679 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,58 @@ # NewsData MCP Server -An MCP server for [NewsData.io](https://newsdata.io/documentation) that exposes real-time and historical news tools to any MCP-compatible client. +An MCP server for [NewsData.io](https://newsdata.io/documentation) that exposes real-time, historical, crypto, market, source-discovery, and aggregate-count tools to any MCP-compatible client. ## Available Tools | Tool | Endpoint | Description | |---|---|---| | `get_latest_news` | `/api/1/latest` | Recent and breaking news (last 48h) | -| `get_archive_news` | `/api/1/archive` | Historical news with `from_date`/`to_date` | +| `get_archive_news` | `/api/1/archive` | Historical news, filterable by `from_date` / `to_date` | | `get_crypto_news` | `/api/1/crypto` | Crypto and blockchain-focused coverage | | `get_market_news` | `/api/1/market` | Stock, financial, and market-related news | | `get_news_sources` | `/api/1/sources` | Source discovery by country, category, or language | +| `get_news_counts` | `/api/1/count` | Aggregate article counts over a date range (`hour` / `day` buckets or single `all` total) | +| `get_crypto_counts` | `/api/1/crypto/count` | Aggregate crypto article counts over a date range | +| `get_market_counts` | `/api/1/market/count` | Aggregate market article counts over a date range | + +All tools are read-only and idempotent; the MCP-protocol annotations let compatible clients (Claude Code, MCP Inspector, etc.) cache and parallelize calls. --- ## Installation -### Clone the Repository - ```bash git clone https://github.com/newsdataapi/newsdata.io-mcp.git cd newsdata.io-mcp uv sync ``` -### Configure Environment +### Configure environment -Create a `.env` file in the project root: +Copy `.env.example` to `.env` and fill in your API key: -```env -NEWSDATA_API_KEY=your_newsdata_api_key -REQUEST_TIMEOUT=30 +```bash +cp .env.example .env +# then edit .env ``` -- `NEWSDATA_API_KEY` — **Required.** -- `REQUEST_TIMEOUT` — Optional. Defaults to `30` seconds. +| Variable | Default | Notes | +|---|---|---| +| `NEWSDATA_API_KEY` | _(required)_ | NewsData.io credential. Missing key returns an error envelope on every call. | +| `REQUEST_TIMEOUT` | `30` | Per-request timeout in seconds. | +| `NEWSDATA_BASE_URL` | `https://newsdata.io/api/1` | Override for staging or a local mock. | +| `NEWSDATA_MAX_RETRIES` | `5` | Maximum attempts for transient failures (network, 5xx, 429). | +| `NEWSDATA_RETRY_BACKOFF` | `2.0` | Base for exponential backoff (`base * 2^(attempt-1)`). Seconds. | +| `NEWSDATA_RETRY_BACKOFF_MAX` | `60.0` | Cap on a single retry sleep, seconds. | +| `NEWSDATA_INTEGRATION_KEY` | _(unset)_ | Used only by `pytest -m integration`. Without it, live-API tests skip. | + +All values are read at module import time; restart the server after changing them. --- ## Running the Server -### stdio transport (for desktop/CLI clients) +### stdio transport (for desktop / CLI clients) ```bash uv run newsdata-mcp --transport stdio @@ -59,56 +71,44 @@ python -m newsdata_mcp.server --transport stdio python -m newsdata_mcp.server --transport streamable-http --host 0.0.0.0 --port 8000 ``` ---- - -## Docker - -Build the image: +### Version ```bash -docker build -t newsdata-mcp . +uv run newsdata-mcp --version ``` -Run in streamable HTTP mode: +--- + +## Docker ```bash -docker run --rm -p 8000:8000 \ - -e NEWSDATA_API_KEY=your_newsdata_api_key \ - newsdata-mcp +docker build -t newsdata-mcp . +docker run --rm -p 8000:8000 -e NEWSDATA_API_KEY=your_newsdata_api_key newsdata-mcp ``` Run in stdio mode: ```bash -docker run --rm -i \ - -e NEWSDATA_API_KEY=your_newsdata_api_key \ - newsdata-mcp --transport stdio +docker run --rm -i -e NEWSDATA_API_KEY=your_newsdata_api_key newsdata-mcp --transport stdio ``` -Pass a `.env` file or override specific values: +Pass a `.env` file: ```bash -docker run --rm -p 8000:8000 \ - --env-file .env \ - -e REQUEST_TIMEOUT=45 \ - newsdata-mcp +docker run --rm -p 8000:8000 --env-file .env newsdata-mcp ``` -> The Docker image pre-sets `REQUEST_TIMEOUT=30` and installs dependencies from `pyproject.toml` via `pip install .`. +The image is a multistage build: dependencies are installed from `uv.lock` in a `python:3.12-slim` builder, then the resulting venv plus `LICENSE` is copied into a fresh `python:3.12-slim` runtime. The container runs as a non-root `app` user. --- ## Editor & Client Integrations -### Claude Code - -Add the server using the CLI: +The simplest way is to add the server to your MCP client's JSON config. Each client picks up the config on restart. Substitute `/path/to/newsdata.io-mcp` for your local clone path. -```bash -claude mcp add newsdata-mcp -- uv --directory /path/to/newsdata.io-mcp run newsdata-mcp --transport stdio -``` +### Claude Code -Or add it manually to your Claude Code MCP config file (`~/.claude/mcp.json` globally, or `.claude/mcp.json` per project): +Either edit `~/.claude/mcp.json` (global) or `.claude/mcp.json` (per-project): ```json { @@ -125,59 +125,19 @@ Or add it manually to your Claude Code MCP config file (`~/.claude/mcp.json` glo } ``` -Restart Claude Code, then ask it to use the NewsData tools directly in chat. - ---- +Then restart Claude Code. ### Claude Desktop -Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): - -```json -{ - "mcpServers": { - "newsdata-mcp": { - "command": "uv", - "args": ["run", "newsdata-mcp", "--transport", "stdio"], - "cwd": "/path/to/newsdata.io-mcp", - "env": { - "NEWSDATA_API_KEY": "your_newsdata_api_key" - } - } - } -} -``` - -Restart Claude Desktop. The NewsData tools will appear in the tools menu. - ---- +Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows) — same JSON block as above. Restart Claude Desktop. ### Cursor -Create or edit `.cursor/mcp.json` in your project root (or `~/.cursor/mcp.json` globally): - -```json -{ - "mcpServers": { - "newsdata-mcp": { - "command": "uv", - "args": ["run", "newsdata-mcp", "--transport", "stdio"], - "cwd": "/path/to/newsdata.io-mcp", - "env": { - "NEWSDATA_API_KEY": "your_newsdata_api_key" - } - } - } -} -``` - -Restart Cursor. The server will appear under **Cursor Settings → MCP**. - ---- +Create or edit `.cursor/mcp.json` in your project root (or `~/.cursor/mcp.json` globally) — same JSON block. Restart Cursor; the server appears under **Cursor Settings → MCP**. ### VS Code (GitHub Copilot) -Create `.vscode/mcp.json` in your workspace (or add to User Settings as `mcp` key): +Create `.vscode/mcp.json` in your workspace (or add an `mcp` key to user settings): ```json { @@ -195,42 +155,21 @@ Create `.vscode/mcp.json` in your workspace (or add to User Settings as `mcp` ke } ``` -Reload VS Code. The server will be picked up by GitHub Copilot Chat when agent mode is active (`@workspace` or Copilot Edits). - ---- +Reload VS Code. Picked up by Copilot Chat in agent mode. ### Windsurf -Edit `~/.codeium/windsurf/mcp_config.json`: - -```json -{ - "mcpServers": { - "newsdata-mcp": { - "command": "uv", - "args": ["run", "newsdata-mcp", "--transport", "stdio"], - "cwd": "/path/to/newsdata.io-mcp", - "env": { - "NEWSDATA_API_KEY": "your_newsdata_api_key" - } - } - } -} -``` - -Restart Windsurf. The tools will be available to Cascade in agentic mode. - ---- +Edit `~/.codeium/windsurf/mcp_config.json` — same JSON block as the Claude Code example. Restart Windsurf. ### ChatGPT Desktop (OpenAI) -Open **ChatGPT → Settings → Connectors → Add custom connector** and supply the server URL (requires HTTP transport): +Run the server in HTTP mode locally: ```bash uv run newsdata-mcp --transport streamable-http --host 127.0.0.1 --port 8000 ``` -Then register `http://127.0.0.1:8000/mcp` as the connector endpoint in the ChatGPT desktop app settings. +Then in **ChatGPT → Settings → Connectors → Add custom connector**, register `http://127.0.0.1:8000/mcp` as the connector endpoint. --- @@ -239,7 +178,7 @@ Then register `http://127.0.0.1:8000/mcp` as the connector endpoint in the ChatG ```text get_latest_news( q="((pizza OR burger) AND healthy)", - country="us", + country=["us", "gb"], language="en", size=10 ) @@ -256,14 +195,14 @@ get_archive_news( ```text get_crypto_news( - coin="btc,eth", + coin=["btc", "eth"], sentiment="positive" ) ``` ```text get_market_news( - symbol="AAPL,NVDA", + symbol=["AAPL", "NVDA"], country="us" ) ``` @@ -275,12 +214,66 @@ get_news_sources( ) ``` +```text +get_news_counts( + from_date="2024-01-01", + to_date="2024-01-31", + q="bitcoin", + interval="day" +) +``` + +```text +get_market_counts( + from_date="2024-01-01", + to_date="2024-03-31", + symbol=["AAPL", "NVDA"], + interval="hour" +) +``` + +```text +get_latest_news( + q="elections", + sentiment="positive", + sentiment_score=70 +) +``` + +Notes on parameter shapes: +- CSV-style filters accept either a Python list (preferred) or a comma-separated string. +- Boolean flags accept `True`/`False` or `1`/`0`. +- `timeframe` accepts an integer for hours (e.g. `24`) or a string with `m` suffix for minutes (e.g. `90m`). +- `interval` (count tools only) accepts `hour`, `day`, or `all` (`all` returns a single aggregate count instead of buckets). +- `sentiment_score` is a 0–100 minimum confidence percentage and requires `sentiment` to also be set — e.g. `sentiment="positive", sentiment_score=70` returns only articles whose positive-sentiment score is at least 70. + --- ## Notes - Latest, crypto, and market endpoints return recent coverage — typically up to 48 hours. - Free plan results are delayed relative to paid plans. -- Result size is capped by plan tier: commonly 10 results on free, up to 50 on paid plans. +- Result `size` is capped by plan tier: commonly 10 results on free, up to 50 on paid plans. +- The count endpoints return aggregate buckets (one per `interval` slot) rather than article content. +- Every tool returns plain text (the MCP-protocol return type). Errors come back as `Error (HTTP 4xx): …` with the status code and a friendly message; HTTP 429 errors include a `retry after Ns` hint when the upstream `Retry-After` header was parseable. + +Full API reference: [https://newsdata.io/documentation](https://newsdata.io/documentation). + +--- + +## Development + +```bash +uv sync --all-groups # install dev deps +uv run pytest # unit tests only (default) +NEWSDATA_INTEGRATION_KEY= uv run pytest -m integration # live-API tests +uv run pytest --cov=newsdata_mcp --cov-report=term-missing # with coverage +uv run ruff check src/ tests/ +uv run mypy +``` + +CI (`.github/workflows/ci.yml`) runs the same four commands on every push/PR to `main`. + +## License -Full API reference: [https://newsdata.io/documentation](https://newsdata.io/documentation) \ No newline at end of file +MIT. See the [LICENSE](LICENSE) file. diff --git a/pyproject.toml b/pyproject.toml index 30dc3fb..55f658c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,15 @@ [build-system] -requires = ["hatchling>=1.27.0"] +requires = ["hatchling>=1.27.0,<2"] build-backend = "hatchling.build" [project] name = "newsdata-mcp" -version = "0.1.0" -description = "MCP server for NewsData.io real-time, historical, crypto, market, and source discovery endpoints." +dynamic = ["version"] +description = "MCP server for the NewsData.io REST API (latest/archive/crypto/market news, source discovery, aggregate counts)." readme = "README.md" +license = { file = "LICENSE" } requires-python = ">=3.12" -authors = [{ name = "NewsData.io" }] +authors = [{ name = "NewsData.io", email = "contact@newsdata.io" }] keywords = [ "mcp", "model-context-protocol", @@ -19,20 +20,23 @@ keywords = [ "market", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", + "Framework :: AsyncIO", + "Framework :: Pydantic", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "httpx>=0.28.1", - "mcp[cli]>=1.27.0", - "pydantic>=2.7.0", - "python-dotenv>=1.2.2", + "httpx>=0.28.1,<1", + "mcp[cli]>=1.27.0,<2", + "pydantic>=2.7.0,<3", + "python-dotenv>=1.2.2,<2", ] [project.scripts] @@ -41,6 +45,88 @@ newsdata-mcp = "newsdata_mcp.server:main" [project.urls] Homepage = "https://newsdata.io" Documentation = "https://newsdata.io/documentation" +Repository = "https://github.com/newsdataapi/newsdata.io-mcp" +Issues = "https://github.com/newsdataapi/newsdata.io-mcp/issues" + +# Single source of truth for the version: read by hatch from +# __init__.py:__version__. Bump by editing __init__.py only. +[tool.hatch.version] +path = "src/newsdata_mcp/__init__.py" [tool.hatch.build.targets.wheel] packages = ["src/newsdata_mcp"] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=5.0.0", + "respx>=0.21.1", + "ruff>=0.6.0", + "mypy>=1.10.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "integration: live API tests, opt-in via NEWSDATA_INTEGRATION_KEY env var", +] +# Skip integration tests by default; opt in with `pytest -m integration` +# or `-m ""` to run everything. +addopts = [ + "-ra", + "--strict-markers", + "-m", "not integration", +] +filterwarnings = [ + "ignore::DeprecationWarning", +] + +[tool.coverage.run] +source = ["src/newsdata_mcp"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "RET", # flake8-return +] + +[tool.ruff.lint.per-file-ignores] +# Tests sometimes need to import-then-use private names and have long +# assertions; relax the strictest rules. +"tests/*" = ["E501"] + +[tool.mypy] +python_version = "3.12" +files = ["src/newsdata_mcp"] +warn_unused_ignores = true +warn_redundant_casts = true +disallow_untyped_defs = true +no_implicit_optional = true +# All of our runtime deps (httpx, pydantic, mcp) ship type info, so we +# don't need to silence missing-import errors; keep mypy strict and +# let it flag any future dep that doesn't. +ignore_missing_imports = false diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 60a3fa5..0000000 --- a/requirements.txt +++ /dev/null @@ -1,36 +0,0 @@ -annotated-doc==0.0.4 -annotated-types==0.7.0 -anyio==4.13.0 -attrs==26.1.0 -certifi==2026.4.22 -cffi==2.0.0 -click==8.3.2 -cryptography==46.0.7 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -httpx-sse==0.4.3 -idna==3.12 -jsonschema==4.26.0 -jsonschema-specifications==2025.9.1 -markdown-it-py==4.0.0 -mcp==1.27.0 -mdurl==0.1.2 -pycparser==3.0 -pydantic==2.13.3 -pydantic-core==2.46.3 -pydantic-settings==2.14.0 -pygments==2.20.0 -pyjwt==2.12.1 -python-dotenv==1.2.2 -python-multipart==0.0.26 -referencing==0.37.0 -rich==15.0.0 -rpds-py==0.30.0 -shellingham==1.5.4 -sse-starlette==3.3.4 -starlette==1.0.0 -typer==0.24.1 -typing-extensions==4.15.0 -typing-inspection==0.4.2 -uvicorn==0.45.0 diff --git a/src/newsdata_mcp/__init__.py b/src/newsdata_mcp/__init__.py index e69de29..73709d4 100644 --- a/src/newsdata_mcp/__init__.py +++ b/src/newsdata_mcp/__init__.py @@ -0,0 +1,10 @@ +"""NewsData MCP server — package init. + +Hatch reads ``__version__`` directly from this file at build time (see +``[tool.hatch.version]`` in ``pyproject.toml``), so bumping the version +is a one-line edit here. The installed-package version read by +``importlib.metadata.version("newsdata-mcp")`` will match. +""" +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/src/newsdata_mcp/_mcp.py b/src/newsdata_mcp/_mcp.py new file mode 100644 index 0000000..8f293c3 --- /dev/null +++ b/src/newsdata_mcp/_mcp.py @@ -0,0 +1,84 @@ +"""The shared ``FastMCP`` instance and module-wide tool annotations. + +Lives in its own module so each `tools/*.py` file can decorate handlers +against the same ``mcp`` singleton without creating a circular import +with the CLI entry point (``server.py``). +""" +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from mcp.server.fastmcp import FastMCP +from mcp.types import ToolAnnotations + +from .http import close_client + +# Every NewsData tool is read-only (no upstream mutation), idempotent +# (same inputs yield the same answer within NewsData's time window), +# and touches an external system (newsdata.io). Sharing one constant +# keeps all eight `@mcp.tool()` decorators consistent. +READ_ONLY_TOOL = ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, +) + + +@asynccontextmanager +async def _lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: + """Setup/teardown that runs around the server's lifetime. + + Startup does nothing proactive — the httpx client is lazy by + design. Shutdown closes the singleton client so the connection + pool is released cleanly instead of leaking until process exit. + """ + try: + yield {} + finally: + await close_client() + + +mcp = FastMCP( + name="newsdata", + lifespan=_lifespan, + instructions=""" + Real-time and historical news via newsdata.io. + + Article-content tools (return article text, metadata, links): + - `get_latest_news` → real-time news from last 48 hours. Use for "latest", "recent", "today" queries. + - `get_archive_news` → historical news older than 48 hours. Use when a date range is given. + - `get_crypto_news` → crypto-only news. Use when query is about bitcoin, ethereum, or any coin. + - `get_market_news` → stock/financial news. Use when query is about stocks, tickers, or companies. + + Aggregate-count tools (return per-bucket article counts over a date range, NOT article content): + - `get_news_counts` → general article counts. Use when the user asks how many articles per day/week/month. + - `get_crypto_counts` → crypto article counts. + - `get_market_counts` → market/financial article counts. + + Source-discovery tool: + - `get_news_sources` → discover available sources. Use when user wants to explore what sources exist. + + Strict rules — enforced locally on every tool (where applicable). + Violating any of these returns "Error: ..." without contacting the API: + - Use only ONE of `q`, `q_in_title`, or `q_in_meta` per request. + - Do NOT combine `country` with `exclude_country`. + - Do NOT combine `category` with `exclude_category`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + - `sentiment_score` (where present) requires `sentiment` to also be set. + + Other guidance (advisory; not locally enforced): + - Never pass None, null, or empty string — omit optional parameters entirely. + - Multi-value filters (country, exclude_country, language, exclude_language, + category, exclude_category, tag, region, domain, exclude_domain, domainurl, + coin, symbol, organization, article_id, creator, datatype, excludefield) + accept EITHER a Python list `['us', 'gb']` OR a comma-separated string `'us,gb'`. + Lists are preferred for clarity. + - Boolean flags (image, video, full_content, removeduplicate) accept True/False (preferred) + or 1/0. For removeduplicate, pass True to enable; pass False or omit to disable + (the API rejects 0). + - timeframe accepts plain integers for hours (e.g. `24`) or strings with `m` suffix + for minutes (e.g. `90m`). + """ +) diff --git a/src/newsdata_mcp/app.py b/src/newsdata_mcp/app.py deleted file mode 100644 index 1051876..0000000 --- a/src/newsdata_mcp/app.py +++ /dev/null @@ -1,22 +0,0 @@ -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP( - name="newsdata", - instructions=""" - Real-time and historical news via newsdata.io. - - Available tools and when to use them: - - `get_latest_news` → real-time news from last 48 hours. Use for "latest", "recent", "today" queries. - - `get_archive_news` → historical news older than 48 hours. Use when a date range is given. - - `get_crypto_news` → crypto-only news. Use when query is about bitcoin, ethereum, or any coin. - - `get_market_news` → stock/financial news. Use when query is about stocks, tickers, or companies. - - `get_news_sources` → discover available sources. Use when user wants to explore what sources exist. - - Global rules that apply to ALL tools: - - Never pass None, null, or empty string — omit optional parameters entirely. - - Never combine an include and its exclude for the same field (e.g. country + exclude_country). - - Always use only ONE of q, q_in_title, or q_in_meta per request. - - Comma-separated filters must have no spaces around commas. - - Boolean flags use 1 or 0, not True or False. - """ -) \ No newline at end of file diff --git a/src/newsdata_mcp/client.py b/src/newsdata_mcp/client.py deleted file mode 100644 index b849c56..0000000 --- a/src/newsdata_mcp/client.py +++ /dev/null @@ -1,614 +0,0 @@ -import httpx -from typing import Any, Optional - - -from .config import ( - ARTICLE_IDS, - CATEGORY_FILTER, - COIN_FILTER, - COUNTRY_FILTER, - DATE_OR_DATETIME, - DOMAIN_FILTER, - DOMAIN_URL_FILTER, - EXCLUDE_FIELD_FILTER, - FLAG, - LANGUAGE_FILTER, - NEWSDATA_API_KEY, - NEWSDATA_BASE_URL, - ORGANIZATION_FILTER, - PAGE, - PRIORITY_DOMAIN, - QUERY, - REGION_FILTER, - REMOVE_DUPLICATE, - REQUEST_TIMEOUT, - SENTIMENT, - SIZE, - SORT, - SYMBOL_FILTER, - TAG_FILTER, - TIMEFRAME, - TIMEZONE, - URL, -) -from .app import mcp - - -async def fetch(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: - clean = {key: value for key, value in params.items() if value is not None} - clean["apikey"] = NEWSDATA_API_KEY - - try: - async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: - response = await client.get(f"{NEWSDATA_BASE_URL}/{endpoint}", params=clean) - response.raise_for_status() - data = response.json() - - if data.get("status") == "success": - return {'status': 'success', 'data': data} - - return {'status': 'error', 'message': f"Something went wrong. Error details: {response.text}"} - - except httpx.TimeoutException: - return {'status': 'error', 'message': f"Request timed out after {REQUEST_TIMEOUT} seconds. The Newsdata.io API may be experiencing delays."} - except httpx.ConnectError: - return {'status': 'error', 'message': "Failed to connect to Newsdata.io API. Please check your internet connection."} - except httpx.HTTPStatusError as e: - code = e.response.status_code - if code == 401: - return {'status': 'error', 'message': "Unauthorized. API key is invalid."} - elif code == 422: - return {'status': 'error', 'message': "Invalid parameters provided. Please check your request."} - elif code == 429: - return {'status': 'error', 'message': "Rate limit exceeded. Try again later."} - return {'status': 'error', 'message': f"HTTP error occurred with Newsdata.io API: {str(e)} - Response: {e.response.text}"} - except Exception as e: - return {'status': 'error', 'message': f"Unexpected error occurred with Newsdata.io API: {str(e)}"} - - -def _format_sentiment_stats(value: Any) -> Optional[str]: - if not isinstance(value, dict) or not value: - return None - - sentiment_stats = [] - for key, val in value.items(): - sentiment_stats.append(f"{key}={val}") - - return ", ".join(sentiment_stats) - - -def _append_field(lines: list[str], label: str, value: Any) -> None: - if value is None: - return - - if isinstance(value, bool): - rendered = "true" if value else "false" - elif isinstance(value, list): - rendered = ", ".join([str(item) for item in value if item]) - else: - rendered = str(value) - - lines.append(f"{label}: {rendered}") - - -def _format_article_item(article: dict[str, Any]) -> list[str]: - lines = [] - _append_field(lines, "article_id", article.get("article_id")) - _append_field(lines, "url", article.get("link")) - _append_field(lines, "title", article.get("title")) - _append_field(lines, "description", article.get("description")) - _append_field(lines, "content", article.get("content")) - _append_field(lines, "published_at", article.get("pubDate")) - _append_field(lines, "published_timezone", article.get("pubDateTZ")) - _append_field(lines, "fetched_at", article.get("fetched_at")) - _append_field(lines, "source_name", article.get("source_name")) - _append_field(lines, "source_id", article.get("source_id")) - _append_field(lines, "source_url", article.get("source_url")) - _append_field(lines, "source_icon", article.get("source_icon")) - _append_field(lines, "source_priority", article.get("source_priority")) - _append_field(lines, "language", article.get("language")) - _append_field(lines, "countries", article.get("country")) - _append_field(lines, "categories", article.get("category")) - _append_field(lines, "datatype", article.get("datatype")) - _append_field(lines, "creators", article.get("creator")) - _append_field(lines, "keywords", article.get("keywords")) - _append_field(lines, "coins", article.get("coin")) - _append_field(lines, "symbols", article.get("symbol")) - _append_field(lines, "sentiment", article.get("sentiment")) - _append_field(lines, "sentiment_stats", _format_sentiment_stats(article.get("sentiment_stats"))) - _append_field(lines, "ai_tags", article.get("ai_tag")) - _append_field(lines, "ai_regions", article.get("ai_region")) - _append_field(lines, "ai_orgs", article.get("ai_org")) - _append_field(lines, "image_url", article.get("image_url")) - _append_field(lines, "video_url", article.get("video_url")) - _append_field(lines, "duplicate", article.get("duplicate")) - _append_field(lines, "summary", article.get("summary")) - - return lines - - -def _format_articles(data: dict[str, Any], endpoint_name: str) -> str: - if data.get("status") != 'success': - return f"Error: {data.get('message', 'Unknown error')}" - - data = data.get("data", {}) or {} - articles = data.get("results") or [] - if not articles: - return f"No {endpoint_name} articles found matching your query." - - total = data.get("totalResults", len(articles)) - next_page = data.get("nextPage") - - lines = [ - f"endpoint: {endpoint_name}", - f"total_results: {total}", - f"returned_results: {len(articles)}", - f"next_page: {next_page or 'none'}", - "", - ] - - for index, article in enumerate(articles, 1): - lines.append(f"Article {index}:") - lines.extend(_format_article_item(article)) - lines.append("") - - return "\n".join(lines) - - -def _format_sources(data: dict[str, Any]) -> str: - if data.get("status") != 'success': - return f"Error: {data.get('message', 'Unknown error')}" - - data = data.get("data", {}) or {} - sources = data.get("results") or [] - if not sources: - return "No source found matching your query." - - lines = [ - "endpoint: sources", - f"returned_results: {len(sources)}", - "", - ] - - for index, source in enumerate(sources, 1): - lines.append(f"Source {index}:") - _append_field(lines, "source_id", source.get("id")) - _append_field(lines, "url", source.get("url")) - _append_field(lines, "description", source.get("description")) - _append_field(lines, "icon", source.get("icon")) - _append_field(lines, "priority", source.get("priority")) - _append_field(lines, "languages", source.get("language")) - _append_field(lines, "countries", source.get("country")) - _append_field(lines, "categories", source.get("category")) - _append_field(lines, "total_article", source.get("total_article")) - _append_field(lines, "last_fetch", source.get("last_fetch")) - lines.append("") - - return "\n".join(lines) - - -@mcp.tool() -async def get_latest_news( - q: Optional[QUERY] = None, - q_in_title: Optional[QUERY] = None, - q_in_meta: Optional[QUERY] = None, - country: Optional[COUNTRY_FILTER] = None, - exclude_country: Optional[COUNTRY_FILTER] = None, - category: Optional[CATEGORY_FILTER] = None, - exclude_category: Optional[CATEGORY_FILTER] = None, - language: Optional[LANGUAGE_FILTER] = None, - exclude_language: Optional[LANGUAGE_FILTER] = None, - domain: Optional[DOMAIN_FILTER] = None, - domain_url: Optional[DOMAIN_URL_FILTER] = None, - exclude_domain: Optional[DOMAIN_FILTER] = None, - timeframe: Optional[TIMEFRAME] = None, - size: Optional[SIZE] = None, - timezone: Optional[TIMEZONE] = None, - full_content: Optional[FLAG] = None, - image: Optional[FLAG] = None, - video: Optional[FLAG] = None, - priority_domain: Optional[PRIORITY_DOMAIN] = None, - page: Optional[PAGE] = None, - tag: Optional[TAG_FILTER] = None, - sentiment: Optional[SENTIMENT] = None, - region: Optional[REGION_FILTER] = None, - exclude_field: Optional[EXCLUDE_FIELD_FILTER] = None, - remove_duplicate: Optional[REMOVE_DUPLICATE] = None, - article_id: Optional[ARTICLE_IDS] = None, - organization: Optional[ORGANIZATION_FILTER] = None, - url: Optional[URL] = None, - sort: Optional[SORT] = None, -) -> str: - """ - Use this tool to fetch REAL-TIME or RECENT news articles (last 48 hours max). - For older articles, use `get_archive_news` instead. - For crypto-specific news, use `get_crypto_news`. - For stock/market news, use `get_market_news`. - - Key rules: - - Use only ONE of `q`, `q_in_title`, or `q_in_meta` per request. - - `q` searches full content. `q_in_title` restricts to title only. `q_in_meta` searches metadata. - - Do NOT combine include/exclude for the same field (e.g. `country` + `exclude_country`). - - Use `timeframe` to restrict to last N hours/minutes. Omit for latest articles with no time filter. - - `article_id` and `url` are for fetching one specific known article, not for search. - - `tag` filters by AI-generated topic tags (e.g. "blockchain", "climate"). - - `region` filters by city-country pairs (e.g. "delhi-india"). - - `organization` filters by company/org name mentions in articles. - - Boolean query syntax (AND, OR, NOT only — no other operators): - - AND → both terms must appear: `AI AND regulation` - - OR → either term matches: `earthquake OR tsunami` - - NOT → exclude a term: `apple NOT fruit` - - "" → exact phrase match: `"interest rate"` - - () → group terms: `(apple OR google) AND earnings` - - Combine freely: `("climate change" OR "global warming") AND policy NOT opinion` - - Examples: - - `q="(bitcoin OR ethereum) AND regulation", country="us", language="en", size=10` - - `q="(OpenAI OR Anthropic OR Google) AND (AI OR LLM) AND regulation", language="en", timeframe="24"` - - `q='"interest rate" AND (Fed OR "Federal Reserve") NOT rumor', country="us", priority_domain="top"` - - `q_in_title="(inflation OR recession) AND (Fed OR "Federal Reserve") NOT forecast", language="en"` - - `q="(merger OR acquisition OR takeover) NOT (denied OR failed OR blocked)", sort="relevancy"` - - `category="technology", priority_domain="top", sort="relevancy"` - - `q="apple earnings", organization="apple", timeframe="24"` - - `category="sports", country="in", language="hi"` - """ - data = await fetch( - "latest", - { - "q": q, - "qInTitle": q_in_title, - "qInMeta": q_in_meta, - "country": country, - "excludecountry": exclude_country, - "category": category, - "excludecategory": exclude_category, - "language": language, - "excludelanguage": exclude_language, - "domain": domain, - "domainurl": domain_url, - "excludedomain": exclude_domain, - "timeframe": timeframe, - "size": size, - "timezone": timezone, - "full_content": full_content, - "image": image, - "video": video, - "prioritydomain": priority_domain, - "page": page, - "tag": tag, - "sentiment": sentiment, - "region": region, - "excludefield": exclude_field, - "removeduplicate": remove_duplicate, - "id": article_id, - "organization": organization, - "url": url, - "sort": sort, - }, - ) - return _format_articles(data, "latest") - - -@mcp.tool() -async def get_archive_news( - q: Optional[QUERY] = None, - q_in_title: Optional[QUERY] = None, - q_in_meta: Optional[QUERY] = None, - country: Optional[COUNTRY_FILTER] = None, - exclude_country: Optional[COUNTRY_FILTER] = None, - category: Optional[CATEGORY_FILTER] = None, - exclude_category: Optional[CATEGORY_FILTER] = None, - language: Optional[LANGUAGE_FILTER] = None, - exclude_language: Optional[LANGUAGE_FILTER] = None, - domain: Optional[DOMAIN_FILTER] = None, - domain_url: Optional[DOMAIN_URL_FILTER] = None, - exclude_domain: Optional[DOMAIN_FILTER] = None, - size: Optional[SIZE] = None, - timezone: Optional[TIMEZONE] = None, - full_content: Optional[FLAG] = None, - image: Optional[FLAG] = None, - video: Optional[FLAG] = None, - priority_domain: Optional[PRIORITY_DOMAIN] = None, - page: Optional[PAGE] = None, - from_date: Optional[DATE_OR_DATETIME] = None, - to_date: Optional[DATE_OR_DATETIME] = None, - exclude_field: Optional[EXCLUDE_FIELD_FILTER] = None, - article_id: Optional[ARTICLE_IDS] = None, - url: Optional[URL] = None, - sort: Optional[SORT] = None, - remove_duplicate: Optional[REMOVE_DUPLICATE] = None, -) -> str: - """ - Use this tool to search HISTORICAL news articles older than 48 hours. - For real-time/recent news, use `get_latest_news` instead. - - Key rules: - - Use `from_date` and/or `to_date` to define the historical date range. - - Dates can be `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` for precision. - - `timeframe` is NOT available on this endpoint — use date range instead. - - Use only ONE of `q`, `q_in_title`, or `q_in_meta` per request. - - Do NOT combine include/exclude for the same field. - - `article_id` or `url` can fetch a single specific historical article. - - Boolean query syntax (AND, OR, NOT only — no other operators): - - AND → both terms must appear: `AI AND regulation` - - OR → either term matches: `earthquake OR tsunami` - - NOT → exclude a term: `apple NOT fruit` - - "" → exact phrase match: `"interest rate"` - - () → group terms: `(apple OR google) AND earnings` - - Combine freely: `("rate hike" OR "rate cut") AND Fed NOT rumor` - - Examples: - - `q="(ukraine AND war) AND (russia OR putin) NOT propaganda", from_date="2024-01-01", to_date="2024-01-31", language="en"` - - `q='"interest rate" AND (Fed OR "Federal Reserve") AND (hike OR cut OR pause) NOT forecast', from_date="2024-01-01", to_date="2024-06-30"` - - `q="(Tesla OR TSLA) AND (recall OR lawsuit OR accident) NOT earnings", from_date="2023-01-01", to_date="2023-12-31"` - - `q_in_title="(election OR vote OR ballot) AND (fraud OR controversy) NOT satire", country="us", from_date="2024-11-01", to_date="2024-11-30"` - - `category="politics", country="us", from_date="2024-11-01", to_date="2024-11-30"` - - `q="IPO", from_date="2025-01-01 00:00:00", sort="relevancy"` - """ - data = await fetch( - "archive", - { - "q": q, - "qInTitle": q_in_title, - "qInMeta": q_in_meta, - "country": country, - "excludecountry": exclude_country, - "category": category, - "excludecategory": exclude_category, - "language": language, - "excludelanguage": exclude_language, - "domain": domain, - "domainurl": domain_url, - "excludedomain": exclude_domain, - "size": size, - "timezone": timezone, - "full_content": full_content, - "image": image, - "video": video, - "prioritydomain": priority_domain, - "page": page, - "from_date": from_date, - "to_date": to_date, - "excludefield": exclude_field, - "id": article_id, - "url": url, - "sort": sort, - "removeduplicate": remove_duplicate, - }, - ) - return _format_articles(data, "archive") - - -@mcp.tool() -async def get_crypto_news( - q: Optional[QUERY] = None, - q_in_title: Optional[QUERY] = None, - q_in_meta: Optional[QUERY] = None, - language: Optional[LANGUAGE_FILTER] = None, - exclude_language: Optional[LANGUAGE_FILTER] = None, - domain: Optional[DOMAIN_FILTER] = None, - domain_url: Optional[DOMAIN_URL_FILTER] = None, - exclude_domain: Optional[DOMAIN_FILTER] = None, - timeframe: Optional[TIMEFRAME] = None, - size: Optional[SIZE] = None, - timezone: Optional[TIMEZONE] = None, - full_content: Optional[FLAG] = None, - image: Optional[FLAG] = None, - video: Optional[FLAG] = None, - priority_domain: Optional[PRIORITY_DOMAIN] = None, - page: Optional[PAGE] = None, - tag: Optional[TAG_FILTER] = None, - sentiment: Optional[SENTIMENT] = None, - coin: Optional[COIN_FILTER] = None, - exclude_field: Optional[EXCLUDE_FIELD_FILTER] = None, - from_date: Optional[DATE_OR_DATETIME] = None, - to_date: Optional[DATE_OR_DATETIME] = None, - remove_duplicate: Optional[REMOVE_DUPLICATE] = None, - article_id: Optional[ARTICLE_IDS] = None, - url: Optional[URL] = None, - sort: Optional[SORT] = None, -) -> str: - """ - Use this tool for CRYPTOCURRENCY news only. It searches a crypto-focused article index. - For general financial/stock news, use `get_market_news` instead. - For general news, use `get_latest_news`. - - Key rules: - - Use `coin` to filter by crypto ticker symbols (e.g. `btc`, `eth,sol`). - - Use `q` for keyword search on top of coin filter, or alone if no specific coin. - - `coin` and `q` can be combined: `coin="btc", q="ETF"`. - - Use only ONE of `q`, `q_in_title`, or `q_in_meta` per request. - - `country` and `category` are NOT available on this endpoint. - - Use `timeframe` OR `from_date`/`to_date`, not both. - - `sentiment` is useful here: `positive` for bullish news, `negative` for bearish. - - Boolean query syntax (AND, OR, NOT only — no other operators): - - AND → both terms must appear: `ETF AND approval` - - OR → either term matches: `hack OR exploit` - - NOT → exclude a term: `bitcoin NOT "bitcoin cash"` - - "" → exact phrase match: `"spot ETF"` - - () → group terms: `(hack OR exploit) AND ethereum` - - Combine freely: `("smart contract" OR DeFi) AND (hack OR exploit) NOT "bug bounty"` - - Examples: - - `coin="btc,eth", language="en", sentiment="positive"` - - `q="(ETF OR "spot ETF") AND (approval OR launch) NOT rejection", coin="btc", timeframe="24"` - - `q='("smart contract" OR DeFi OR dApp) AND (hack OR exploit OR vulnerability) NOT "bug bounty"', coin="eth"` - - `q="(halving OR "block reward") AND (price OR rally OR bull) NOT prediction", coin="btc", sort="relevancy"` - - `q_in_title="(SEC OR regulation OR ban) AND (crypto OR bitcoin OR blockchain) NOT rumor", language="en"` - - `coin="sol", from_date="2025-01-01", to_date="2025-01-31"` - """ - data = await fetch( - "crypto", - { - "q": q, - "qInTitle": q_in_title, - "qInMeta": q_in_meta, - "language": language, - "excludelanguage": exclude_language, - "domain": domain, - "domainurl": domain_url, - "excludedomain": exclude_domain, - "timeframe": timeframe, - "size": size, - "timezone": timezone, - "full_content": full_content, - "image": image, - "video": video, - "prioritydomain": priority_domain, - "page": page, - "tag": tag, - "sentiment": sentiment, - "coin": coin, - "excludefield": exclude_field, - "from_date": from_date, - "to_date": to_date, - "removeduplicate": remove_duplicate, - "id": article_id, - "url": url, - "sort": sort, - }, - ) - return _format_articles(data, "crypto") - - -@mcp.tool() -async def get_market_news( - q: Optional[QUERY] = None, - q_in_title: Optional[QUERY] = None, - q_in_meta: Optional[QUERY] = None, - from_date: Optional[DATE_OR_DATETIME] = None, - to_date: Optional[DATE_OR_DATETIME] = None, - domain: Optional[DOMAIN_FILTER] = None, - language: Optional[LANGUAGE_FILTER] = None, - page: Optional[PAGE] = None, - full_content: Optional[FLAG] = None, - image: Optional[FLAG] = None, - video: Optional[FLAG] = None, - timeframe: Optional[TIMEFRAME] = None, - priority_domain: Optional[PRIORITY_DOMAIN] = None, - timezone: Optional[TIMEZONE] = None, - size: Optional[SIZE] = None, - domain_url: Optional[DOMAIN_URL_FILTER] = None, - exclude_domain: Optional[DOMAIN_FILTER] = None, - tag: Optional[TAG_FILTER] = None, - sentiment: Optional[SENTIMENT] = None, - article_id: Optional[ARTICLE_IDS] = None, - exclude_field: Optional[EXCLUDE_FIELD_FILTER] = None, - remove_duplicate: Optional[REMOVE_DUPLICATE] = None, - exclude_language: Optional[LANGUAGE_FILTER] = None, - organization: Optional[ORGANIZATION_FILTER] = None, - url: Optional[URL] = None, - sort: Optional[SORT] = None, - symbol: Optional[SYMBOL_FILTER] = None, - country: Optional[COUNTRY_FILTER] = None, - exclude_country: Optional[COUNTRY_FILTER] = None, -) -> str: - """ - Use this tool for STOCK MARKET and FINANCIAL news. - For crypto news, use `get_crypto_news` instead. - For general news, use `get_latest_news`. - - Key rules: - - Use `symbol` for stock/market tickers (e.g. `AAPL`, `TSLA,NVDA`). - - Use `organization` for company name filtering (e.g. `tesla,apple`). - - `symbol` and `organization` can be combined for precision. - - Use only ONE of `q`, `q_in_title`, or `q_in_meta` per request. - - `category` is NOT available on this endpoint. - - Do NOT combine `country` with `exclude_country`. - - Use `timeframe` OR `from_date`/`to_date`, not both. - - Boolean query syntax (AND, OR, NOT only — no other operators): - - AND → both terms must appear: `earnings AND beat` - - OR → either term matches: `layoff OR restructuring` - - NOT → exclude a term: `Apple NOT iPhone` - - "" → exact phrase match: `"quarterly results"` - - () → group terms: `(earnings OR revenue) AND beat` - - Combine freely: `(merger OR acquisition) AND tech NOT (blocked OR failed)` - - Examples: - - `symbol="AAPL,MSFT", language="en", sort="relevancy"` - - `q="(earnings OR revenue OR profit) AND (beat OR miss OR guidance) NOT rumor", symbol="AAPL,MSFT,GOOGL"` - - `q='("merger" OR "acquisition" OR "takeover") AND NOT (denied OR failed OR blocked)', country="us", priority_domain="top"` - - `q="(layoff OR layoffs OR restructuring) AND (tech OR technology) NOT recovery", language="en", timeframe="48"` - - `q_in_title="(FDA OR approval OR trial) AND (drug OR treatment OR therapy) NOT recall", sort="relevancy"` - - `organization="tesla,nvidia", timeframe="48", sentiment="positive"` - - `q="earnings beat", symbol="NVDA", from_date="2025-01-01"` - - `country="us", priority_domain="top", sort="pubdateasc"` - """ - data = await fetch( - "market", - { - "q": q, - "qInTitle": q_in_title, - "qInMeta": q_in_meta, - "from_date": from_date, - "to_date": to_date, - "domain": domain, - "language": language, - "page": page, - "full_content": full_content, - "image": image, - "video": video, - "timeframe": timeframe, - "prioritydomain": priority_domain, - "timezone": timezone, - "size": size, - "domainurl": domain_url, - "excludedomain": exclude_domain, - "tag": tag, - "sentiment": sentiment, - "id": article_id, - "excludefield": exclude_field, - "removeduplicate": remove_duplicate, - "excludelanguage": exclude_language, - "organization": organization, - "url": url, - "sort": sort, - "symbol": symbol, - "country": country, - "excludecountry": exclude_country, - }, - ) - return _format_articles(data, "market") - - -@mcp.tool() -async def get_news_sources( - country: Optional[COUNTRY_FILTER] = None, - category: Optional[CATEGORY_FILTER] = None, - language: Optional[LANGUAGE_FILTER] = None, - priority_domain: Optional[PRIORITY_DOMAIN] = None, - domain_url: Optional[DOMAIN_URL_FILTER] = None, -) -> str: - """ - Use this tool to DISCOVER available news sources, not to fetch articles. - Use this when the user wants to: - - Find which sources are available for a country or language. - - Get source IDs to use in `domain` filter in other tools. - - Explore what categories a source covers. - - Key rules: - - All parameters are optional — omit to get all available sources. - - Returns source metadata: id, url, priority, languages, countries, categories. - - Use the returned `source_id` values as input to `domain` in other tools. - - No pagination — returns all matching sources in one call. - - Examples: - - `country="in", language="hi"` → Hindi sources in India - - `category="technology", priority_domain="top"` → top tech sources - - `domain_url="reuters.com,bbc.com"` → check if specific domains are available - """ - data = await fetch( - "sources", - { - "country": country, - "category": category, - "language": language, - "prioritydomain": priority_domain, - "domainurl": domain_url, - }, - ) - return _format_sources(data) diff --git a/src/newsdata_mcp/config.py b/src/newsdata_mcp/config.py deleted file mode 100644 index 2e653ff..0000000 --- a/src/newsdata_mcp/config.py +++ /dev/null @@ -1,380 +0,0 @@ -import os -from typing import Annotated, Literal, get_args - -from dotenv import load_dotenv -from pydantic import Field - -load_dotenv() - -NEWSDATA_API_KEY = os.getenv("NEWSDATA_API_KEY") -NEWSDATA_BASE_URL = "https://newsdata.io/api/1" - -REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30")) - -if not NEWSDATA_API_KEY: - import warnings - - warnings.warn("NEWSDATA_API_KEY is not set", stacklevel=2) - - -def _csv_pattern(*values: str) -> str: - choices = "|".join(values) - return rf"^(?:{choices})(?:,(?:{choices}))*$" - - -# NewsData documents 17 category codes in its public endpoint docs/blogs. -CATEGORY_CODE = Literal[ - "business", - "crime", - "domestic", - "education", - "entertainment", - "environment", - "food", - "health", - "lifestyle", - "other", - "politics", - "science", - "sports", - "technology", - "top", - "tourism", - "world", -] - -CATEGORY_FILTER = Annotated[ - str, - Field( - pattern=_csv_pattern(*get_args(CATEGORY_CODE)), - description=( - "Comma-separated NewsData category codes. No spaces. " - f"Allowed: {', '.join(get_args(CATEGORY_CODE))}." - ), - examples=["technology", "technology,science", "business,world"], - ), -] - -PRIORITY_DOMAIN = Annotated[ - Literal["top", "medium", "low"], - Field( - description=( - "Filter results by source credibility tier. " - "`top` = highest credibility sources (top 10% of all sources). " - "`medium` = top 30% of sources. " - "`low` = top 50% of sources. " - "Omit for no filtering — returns articles from all sources. " - "Use `top` when accuracy and source quality matters most." - ), - examples=["top", "medium"], - ), -] - -SORT = Annotated[ - Literal["pubdateasc", "relevancy", "source"], - Field( - description=( - "Pass one sort mode only. Use `pubdateasc` for oldest first, " - "`relevancy` for query relevance, or `source` for source-priority order. " - "Omit this parameter to keep NewsData's default newest-first order." - ), - examples=["pubdateasc", "relevancy"], - ), -] - -SENTIMENT = Annotated[ - Literal["positive", "negative", "neutral"], - Field( - description=( - "Pass one sentiment filter only. Use `positive`, `negative`, or `neutral`." - ), - examples=["positive", "neutral"], - ), -] - -FLAG = Annotated[ - Literal[0, 1], - Field( - description=( - "Binary flag. Pass `1` to filter FOR articles that have this field " - "(e.g. articles with images). Pass `0` to filter for articles WITHOUT it. " - "Omit the parameter entirely if you don't want to filter by it at all. " - "Used for: `image`, `video`, `full_content`." - ), - examples=[1, 0], - ), -] - -REMOVE_DUPLICATE = Annotated[ - Literal[1], - Field( - description=( - "Pass only `1` when you want NewsData to remove duplicate articles. " - "If you do not want that filter, omit the parameter instead of passing `0`." - ), - examples=[1], - ), -] - -SIZE = Annotated[ - int, - Field( - ge=1, - le=50, - description=( - "Pass the number of articles to return in one page. Valid range is 1 to 50. " - "Free plans usually allow up to 10, while paid plans allow up to 50." - ), - examples=[10, 30], - ), -] - -ARTICLE_IDS = Annotated[ - str, - Field( - pattern=r"^[0-9a-f]{32}(?:,[0-9a-f]{32}){0,49}$", - description=( - "Pass one to fifty NewsData `article_id` values as a comma-separated " - "string of lowercase 32-character hex IDs. Do not add spaces." - ), - examples=[ - "668de67f2c32ce652104e7c4a5c9b517", - "668de67f2c32ce652104e7c4a5c9b517,8c2cc0fdb87a3382876dca3448eb4cbc", - ], - ), -] - -QUERY = Annotated[ - str, - Field( - min_length=1, - max_length=512, - description=( - "Full-text search query for `q`, `qInTitle`, or `qInMeta`. " - "Use only ONE of these three in the same request. " - "Supports boolean operators: AND, OR, NOT. " - "Use quotes for exact phrases: '\"climate change\"'. " - "Use parentheses for grouping: '(bitcoin OR ethereum) AND regulation'. " - "Max 512 characters." - ), - examples=[ - "bitcoin", - "bitcoin AND ethereum", - '"climate change" NOT "fossil fuel"', - "(apple OR google) AND earnings", - ], - ), -] - -TIMEFRAME = Annotated[ - str, - Field( - pattern=( - r"^(?:" - r"[1-9]|[1-3][0-9]|4[0-8]" - r"|[1-9][0-9]{0,2}m|1[0-9]{3}m|2[0-7][0-9]{2}m|28[0-7][0-9]m|2880m" - r")$" - ), - description=( - "Time window for recent news. " - "Use plain integers for hours (1-48 only, e.g. `6` = last 6 hours). " - "Use suffix `m` for minutes (1m-2880m, e.g. `90m` = last 90 minutes). " - "2880m and 48 are equivalent maximums. " - "Do NOT pass values like `49` or `3000m` — they will be rejected." - ), - examples=["2", "48", "90m", "2880m"], - ), -] - -COUNTRY_FILTER = Annotated[ - str, - Field( - pattern=r"^[a-z]{2}(?:,[a-z]{2}){0,9}$", - description=( - "Pass one or more lowercase ISO 3166-1 alpha-2 country codes as a " - "comma-separated string. Do not use country names or uppercase letters." - ), - examples=["us", "us,gb", "in,au,jp"], - ), -] - -LANGUAGE_FILTER = Annotated[ - str, - Field( - pattern=r"^[a-z]{2}(?:,[a-z]{2}){0,9}$", - description=( - "Pass one or more lowercase ISO 639-1 language codes as a " - "comma-separated string. Do not use language names." - ), - examples=["en", "en,fr", "hi,bn,ta"], - ), -] - -TAG_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=256, - pattern=r"^[^,]+(?:,[^,]+){0,9}$", - description=( - "Pass one or more NewsData AI tags as a comma-separated string. " - "Use the exact tag text expected by NewsData and do not add spaces " - "around commas." - ), - examples=["food", "tourism,food", "blockchain,markets"], - ), -] - -REGION_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=256, - pattern=r"^[^,]+(?:,[^,]+){0,9}$", - description=( - "Pass one or more NewsData region names as a comma-separated string. " - "Use the region text directly, for example city-country style values." - ), - examples=[ - "new york-united states of america", - "london-united kingdom,dubai-united arab emirates", - ], - ), -] - -DOMAIN_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=255, - pattern=r"^[A-Za-z0-9.-]+(?:,[A-Za-z0-9.-]+){0,9}$", - description=( - "Pass one or more domain identifiers as a comma-separated string " - "without `http://` or `https://`. NewsData accepts values such as " - "short source IDs or hostnames." - ), - examples=["bbc", "coindesk", "reuters.com,bbc.com"], - ), -] - -DOMAIN_URL_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=255, - pattern=r"^[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:,[A-Za-z0-9.-]+\.[A-Za-z]{2,}){0,9}$", - description=( - "Pass one or more full domain hosts as a comma-separated string without " - "protocol or path. Use hostnames like `bbc.com`, not full article URLs." - ), - examples=["bbc.com", "bbc.com,reuters.com"], - ), -] - -EXCLUDE_FIELD_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=256, - pattern=r"^[A-Za-z_]+(?:,[A-Za-z_]+){0,27}$", - description=( - "Pass one or more response field names to exclude as a comma-separated " - "string. Use the field names expected by NewsData, such as `pubdate`, " - "`imageurl`, `content`, or `source_id`." - ), - examples=["pubdate", "pubdate,imageurl", "content,source_id"], - ), -] - -TIMEZONE = Annotated[ - str, - Field( - min_length=3, - max_length=64, - pattern=r"^[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+$", - description=( - "Pass an IANA timezone name. Use values like `Asia/Dubai` or " - "`America/New_York`." - ), - examples=["Asia/Dubai", "America/New_York"], - ), -] - -PAGE = Annotated[ - str, - Field( - min_length=1, - description=( - "Pagination cursor for fetching the next page of results. " - "Only pass this if a previous API response returned a `nextPage` field. " - "On first request, omit this parameter entirely. " - "Copy the token exactly — do not modify, encode, or guess it." - ), - examples=["17349543216784a12c9f0f6fbe7c1234"], - ), -] - -URL = Annotated[ - str, - Field( - pattern=r"^https?://\S+$", - description=( - "Pass a full absolute article URL starting with `http://` or `https://`." - ), - examples=["https://newsdata.io/blog/multiple-api-key-newsdata-io"], - ), -] - -DATE_OR_DATETIME = Annotated[ - str, - Field( - pattern=r"^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}:\d{2})?$", - description=( - "Pass either `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`, depending on the " - "endpoint requirement." - ), - examples=["2025-01-01", "2025-01-01 06:12:45"], - ), -] - -COIN_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=128, - pattern=r"^[A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+){0,9}$", - description=( - "Pass one or more crypto coin symbols as a comma-separated string. " - "Use ticker-style values such as `btc` or `eth,btc`." - ), - examples=["btc", "eth,btc", "sol,ada,xrp"], - ), -] - -SYMBOL_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=128, - pattern=r"^[A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+){0,9}$", - description=( - "Pass one or more market symbols or stock tickers as a comma-separated " - "string." - ), - examples=["AAPL", "AAPL,MSFT", "TSLA,NVDA,AMZN"], - ), -] - -ORGANIZATION_FILTER = Annotated[ - str, - Field( - min_length=1, - max_length=256, - pattern=r"^[^,]+(?:,[^,]+){0,9}$", - description=( - "Pass one or more organization names as a comma-separated string. " - "Use plain organization names and do not add spaces around commas." - ), - examples=["uber", "uber,apple", "tesla,microsoft,google"], - ), -] diff --git a/src/newsdata_mcp/formatters.py b/src/newsdata_mcp/formatters.py new file mode 100644 index 0000000..d74893c --- /dev/null +++ b/src/newsdata_mcp/formatters.py @@ -0,0 +1,251 @@ +"""Pure-text rendering helpers for NewsData responses. + +These functions have no network or framework dependencies; they take a +`fetch()`-style envelope (`{"status": "success"|"error", ...}`) and emit +a flat `label: value` text block suitable as an MCP tool return value. + +Error envelopes from ``http.fetch`` carry an optional ``status_code`` +and ``retry_after``. ``_format_error`` surfaces both in the rendered +string (e.g. ``Error (HTTP 429, retry after 12s): ...``) so the LLM can +parse the failure mode without having to interpret prose. +""" +from typing import Any + + +def _format_error(envelope: dict[str, Any]) -> str: + """Render an error envelope as a one-line string. + + Includes HTTP status code and retry-after seconds when those fields + are present. Format is stable: ``Error[ (HTTP {code}[, retry after + {N}s])]: {message}``. The optional clause uses parens + comma so the + LLM can extract `HTTP \\d+` and `retry after \\d+s` with simple regex. + """ + message = envelope.get("message", "Unknown error") + code = envelope.get("status_code") + retry_after = envelope.get("retry_after") + parts: list[str] = [] + if code is not None: + parts.append(f"HTTP {code}") + if retry_after is not None: + parts.append(f"retry after {retry_after}s") + suffix = f" ({', '.join(parts)})" if parts else "" + return f"Error{suffix}: {message}" + + +def _format_sentiment_stats(value: Any) -> str | None: + """Flatten NewsData's sentiment_stats dict (``{"positive": N, ...}``) + into a CSV string. Returns ``None`` for missing or empty values so + ``_append_field`` skips the line entirely.""" + if not isinstance(value, dict) or not value: + return None + + return ", ".join(f"{key}={val}" for key, val in value.items()) + + +def _clean_text(value: Any) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + return cleaned or None + + +def _append_field(lines: list[str], label: str, value: Any) -> None: + if value is None: + return + + if isinstance(value, bool): + rendered = "true" if value else "false" + elif isinstance(value, list): + rendered = ", ".join([str(item) for item in value if item]) + else: + rendered = str(value) + + lines.append(f"{label}: {rendered}") + + +def _format_article_item(article: dict[str, Any]) -> list[str]: + """Render one article. Fields are ordered most-useful first (title, + url, source, when, summary text) so the LLM can grasp the article + from the first few lines; less-important fields (internal tokens, + media URLs, AI metadata) come later. + """ + lines: list[str] = [] + # What and where. + _append_field(lines, "title", article.get("title")) + _append_field(lines, "url", article.get("link")) + _append_field(lines, "source_name", article.get("source_name")) + _append_field(lines, "published_at", article.get("pubDate")) + # The article text, in increasing length. + _append_field(lines, "description", article.get("description")) + _append_field(lines, "summary", article.get("summary")) + _append_field(lines, "content", article.get("content")) + # Classification and language. + _append_field(lines, "language", article.get("language")) + _append_field(lines, "countries", article.get("country")) + _append_field(lines, "categories", article.get("category")) + _append_field(lines, "keywords", article.get("keywords")) + _append_field(lines, "creators", article.get("creator")) + # Sentiment / AI signals. + _append_field(lines, "sentiment", article.get("sentiment")) + _append_field(lines, "sentiment_stats", _format_sentiment_stats(article.get("sentiment_stats"))) + _append_field(lines, "ai_tags", article.get("ai_tag")) + _append_field(lines, "ai_regions", article.get("ai_region")) + _append_field(lines, "ai_orgs", article.get("ai_org")) + # Domain-specific. + _append_field(lines, "coins", article.get("coin")) + _append_field(lines, "symbols", article.get("symbol")) + _append_field(lines, "datatype", article.get("datatype")) + # Media. + _append_field(lines, "image_url", article.get("image_url")) + _append_field(lines, "video_url", article.get("video_url")) + # Source details (source_name already up top). + _append_field(lines, "source_id", article.get("source_id")) + _append_field(lines, "source_url", article.get("source_url")) + _append_field(lines, "source_icon", article.get("source_icon")) + _append_field(lines, "source_priority", article.get("source_priority")) + # Temporal / internal metadata. + _append_field(lines, "published_timezone", article.get("pubDateTZ")) + _append_field(lines, "fetched_at", article.get("fetched_at")) + _append_field(lines, "duplicate", article.get("duplicate")) + # The internal article id is useful only for follow-up calls + # (article_id=… on the same endpoint); deliberately last. + _append_field(lines, "article_id", article.get("article_id")) + + return lines + + +def format_articles(data: dict[str, Any], endpoint_name: str) -> str: + """Render an article-endpoint response (`/latest`, `/archive`, + `/crypto`, `/market`). Emits an ``endpoint:`` / ``total_results:`` / + ``returned_results:`` / ``next_page:`` header followed by one + ``Article N:`` block per result. The ``next_page`` token is the + cursor to pass back as ``page=`` on the next call; ``none`` means + there are no more pages. + """ + if data.get("status") != "success": + return _format_error(data) + + inner = data.get("data", {}) or {} + articles = inner.get("results") or [] + if not articles: + return f"No {endpoint_name} articles found matching your query." + + total = inner.get("totalResults", len(articles)) + next_page = inner.get("nextPage") + + lines = [ + f"endpoint: {endpoint_name}", + f"total_results: {total}", + f"returned_results: {len(articles)}", + f"next_page: {next_page or 'none'}", + "", + ] + + for index, article in enumerate(articles, 1): + lines.append(f"Article {index}:") + lines.extend(_format_article_item(article)) + lines.append("") + + return "\n".join(lines) + + +def format_counts(data: dict[str, Any], endpoint_name: str) -> str: + """Render a count-endpoint response (`/count`, `/crypto/count`, + `/market/count`). ``results`` may be either a list of bucket dicts + (one per ``interval`` slot — e.g. ``{"dateTime": "...", "count": N}`` + when ``interval="hour"`` or ``"day"``) or a single aggregate dict + (``{"count": N}`` when ``interval="all"`` or omitted). We handle + both shapes. + """ + if data.get("status") != "success": + return _format_error(data) + + inner = data.get("data", {}) or {} + results = inner.get("results") + if not results: + return f"No results found for {endpoint_name} over the given range." + + if isinstance(results, dict): + total = results.get("count", "n/a") + else: + # Match the renderer below: skip non-dict items defensively rather + # than AttributeError if the API ever returns a mixed-shape list. + total = sum( + bucket.get("count", 0) + for bucket in results + if isinstance(bucket, dict) + ) + + next_page = inner.get("nextPage") + + lines = [ + f"endpoint: {endpoint_name}", + f"total_results: {total}", + f"next_page: {next_page or 'none'}", + "", + ] + + if isinstance(results, list): + lines.append(f"buckets: {len(results)}") + lines.append("") + for index, bucket in enumerate(results, 1): + lines.append(f"Bucket {index}:") + if isinstance(bucket, dict): + for key, value in bucket.items(): + _append_field(lines, key, value) + else: + lines.append(f"value: {bucket}") + lines.append("") + elif isinstance(results, dict): + lines.append("aggregate:") + for key, value in results.items(): + _append_field(lines, key, value) + lines.append("") + else: + lines.append(f"raw_results: {results}") + lines.append("") + + return "\n".join(lines) + + +def format_sources(data: dict[str, Any]) -> str: + """Render the `/sources` endpoint response. Emits an ``endpoint:`` / + ``total_results:`` / ``returned_results:`` header followed by one + block per source headed by ``[N] {name}``. Source IDs are the + values to pass as ``domain=…`` on the article tools. + """ + if data.get("status") != "success": + return _format_error(data) + + inner = data.get("data", {}) or {} + sources = inner.get("results") or [] + if not sources: + return "No sources found matching your filters." + + total = inner.get("totalResults", len(sources)) + + lines = [ + "endpoint: sources", + f"total_results: {total}", + f"returned_results: {len(sources)}", + "", + ] + + for index, source in enumerate(sources, 1): + source_id = _clean_text(source.get("id")) or "N/A" + name = _clean_text(source.get("name")) or source_id + lines.append(f"[{index}] {name}") + _append_field(lines, "source_id", source_id) + _append_field(lines, "source_name", source.get("name")) + _append_field(lines, "url", source.get("url")) + _append_field(lines, "icon", source.get("icon")) + _append_field(lines, "priority", source.get("priority")) + _append_field(lines, "languages", source.get("language")) + _append_field(lines, "countries", source.get("country")) + _append_field(lines, "categories", source.get("category")) + _append_field(lines, "total_article", source.get("total_article")) + _append_field(lines, "last_fetch", source.get("last_fetch")) + _append_field(lines, "description", _clean_text(source.get("description"))) + lines.append("") + + return "\n".join(lines) diff --git a/src/newsdata_mcp/http.py b/src/newsdata_mcp/http.py new file mode 100644 index 0000000..43bf0f9 --- /dev/null +++ b/src/newsdata_mcp/http.py @@ -0,0 +1,358 @@ +"""HTTP layer for the NewsData.io REST API. + +Owns a lazy module-level ``httpx.AsyncClient`` so we reuse one +connection pool across tool calls. Authentication (``X-ACCESS-KEY``) +and ``User-Agent`` live on the client. Errors are returned as a +``{"status": "error", "message": ...}`` envelope rather than raised, to +keep tool functions return-type clean (MCP tools must return ``str``). + +Retry policy: + +- Network errors (``TimeoutException``, ``ConnectError``) → retry with + exponential backoff. +- HTTP 5xx → retry with exponential backoff. +- HTTP 429 → retry, honoring ``Retry-After`` (integer seconds or + HTTP-date per RFC 7231) when parseable; falling back to exponential + backoff otherwise. +- HTTP 401/403/422/other 4xx, non-JSON 2xx, soft 200 errors → permanent + failure, never retried. + +Error envelope:: + + {"status": "error", "message": str, "status_code": int | None, + "retry_after": int | None} + +``status_code`` is ``None`` for non-HTTP failures (timeout, missing +key). ``retry_after`` is present only on 429s that included a +parseable header. +""" +import asyncio +import json +import logging +from collections.abc import Mapping +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime +from typing import Any + +import httpx + +from . import __version__ +from .settings import ( + MAX_RETRIES, + NEWSDATA_API_KEY, + NEWSDATA_BASE_URL, + REQUEST_TIMEOUT, + RETRY_BACKOFF, + RETRY_BACKOFF_MAX, +) + +logger = logging.getLogger(__name__) + +# Wire-name keys whose semantics differ from a plain bool→{1,0} mapping. +# `removeduplicate` accepts only `1` server-side (and silently drops the +# call when omitted) — passing `False` means "I don't want the filter", +# which is communicated to NewsData by omitting the parameter, not by +# sending `0`. +_BOOL_TRUTHY_ONLY_KEYS = frozenset({"removeduplicate"}) + +_client: httpx.AsyncClient | None = None +_client_lock = asyncio.Lock() + + +def _normalize_params(params: Mapping[str, Any]) -> dict[str, Any]: + """Coerce user-facing parameter values into the form NewsData expects. + + Centralised here so every tool stays simple and so the LLM can pass + the most natural Python form (True/False for flags, list for CSVs, + int for hour counts) without any tool having to translate. + """ + clean: dict[str, Any] = {} + for key, value in params.items(): + if value is None: + continue + + # bool must be checked before int (bool is an int subclass). + if isinstance(value, bool): + if key in _BOOL_TRUTHY_ONLY_KEYS: + if value: + clean[key] = 1 + # else: omit — caller meant "I don't want this filter". + continue + clean[key] = 1 if value else 0 + continue + + if isinstance(value, list): + items = [str(item) for item in value if item] + if not items: + continue + clean[key] = ",".join(items) + continue + + clean[key] = value + return clean + + +def _parse_retry_after(value: str | None) -> int | None: + """Parse a ``Retry-After`` header into an integer seconds value. + + RFC 7231 allows two forms: + - an integer number of seconds, or + - an HTTP-date. + + Returns ``None`` for unparseable input so the retry loop falls back + to exponential backoff. + """ + if value is None: + return None + value = value.strip() + if not value: + return None + + # Form 1: integer seconds. + try: + seconds = int(value) + except ValueError: + pass + else: + return max(seconds, 0) + + # Form 2: HTTP-date. + try: + target = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if target is None: + return None + if target.tzinfo is None: + target = target.replace(tzinfo=UTC) + delta = (target - datetime.now(tz=UTC)).total_seconds() + return max(int(delta), 0) + + +def _compute_backoff(attempt: int) -> float: + """Exponential backoff: `RETRY_BACKOFF * 2^(attempt-1)`, capped.""" + delay = RETRY_BACKOFF * (2 ** (attempt - 1)) + return min(delay, RETRY_BACKOFF_MAX) + + +async def _get_client() -> httpx.AsyncClient: + """Lazy singleton so we reuse one connection pool across tool calls. + + Callers must guard against ``NEWSDATA_API_KEY is None`` (``fetch`` + does this at the top); we assert it here so the type-checker is + satisfied with the non-Optional header dict. + """ + global _client + if _client is None: + async with _client_lock: + if _client is None: + assert NEWSDATA_API_KEY is not None, ( + "_get_client called without NEWSDATA_API_KEY set" + ) + _client = httpx.AsyncClient( + timeout=REQUEST_TIMEOUT, + headers={ + "User-Agent": f"newsdata-mcp/{__version__}", + "X-ACCESS-KEY": NEWSDATA_API_KEY, + }, + ) + return _client + + +async def close_client() -> None: + """Close the singleton ``httpx.AsyncClient`` if one was created. + + Called from the FastMCP lifespan teardown so we release the + connection pool cleanly on SIGTERM instead of relying on process + exit. Safe to call when no client was ever created. + """ + global _client + if _client is None: + return + async with _client_lock: + if _client is None: + return + try: + await _client.aclose() + finally: + _client = None + + +async def _request_once( + endpoint: str, clean: dict[str, Any] +) -> tuple[dict[str, Any], bool]: + """Execute one HTTP attempt. + + Returns a ``(envelope, retryable)`` tuple. ``envelope`` is the + `{"status": ...}` dict that ``fetch()`` either returns immediately + (when ``retryable=False``) or sleeps-then-retries (when + ``retryable=True`` and we have attempts left). + """ + response: httpx.Response | None = None + try: + client = await _get_client() + response = await client.get( + f"{NEWSDATA_BASE_URL}/{endpoint}", + params=clean, + ) + response.raise_for_status() + data: dict[str, Any] = response.json() + + if data.get("status") == "success": + return {"status": "success", "data": data}, False + + results = data.get("results") + soft_error = results if isinstance(results, dict) else {} + message = ( + soft_error.get("message") + or data.get("message") + or "Unknown API error." + ) + return ( + {"status": "error", "message": message, "status_code": 200}, + False, + ) + + except httpx.TimeoutException: + return ( + { + "status": "error", + "message": ( + f"Request timed out after {REQUEST_TIMEOUT} seconds. " + "The Newsdata.io API may be experiencing delays." + ), + "status_code": None, + }, + True, + ) + except httpx.ConnectError: + return ( + { + "status": "error", + "message": ( + "Failed to connect to Newsdata.io API. " + "Please check your internet connection." + ), + "status_code": None, + }, + True, + ) + except httpx.HTTPStatusError as e: + code = e.response.status_code + retry_after = _parse_retry_after(e.response.headers.get("Retry-After")) + + if code == 401: + return ( + { + "status": "error", + "message": "Unauthorized. API key is invalid.", + "status_code": code, + }, + False, + ) + if code == 422: + return ( + { + "status": "error", + "message": "Invalid parameters provided. Please check your request.", + "status_code": code, + }, + False, + ) + if code == 429: + envelope: dict[str, Any] = { + "status": "error", + "message": "Rate limit exceeded. Try again later.", + "status_code": code, + } + if retry_after is not None: + envelope["retry_after"] = retry_after + return envelope, True + + body = (e.response.text or "").strip() + if len(body) > 500: + body = body[:500] + "…" + envelope = { + "status": "error", + "message": f"HTTP {code} from Newsdata.io: {body}", + "status_code": code, + } + # 5xx are transient; other 4xx are permanent (user-side mistakes). + return envelope, code >= 500 + except json.JSONDecodeError: + body = (response.text or "").strip()[:200] if response is not None else "" + return ( + { + "status": "error", + "message": ( + f"Newsdata.io returned a non-JSON response. " + f"First 200 chars: {body}" + ), + "status_code": None, + }, + False, + ) + except Exception: + logger.exception("Unexpected error calling Newsdata.io") + return ( + { + "status": "error", + "message": ( + "Unexpected error calling Newsdata.io. " + "See server logs for details." + ), + "status_code": None, + }, + False, + ) + + +async def fetch(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: + """Public entry point: one logical request, with retries. + + Permanent failures (auth, validation, soft errors, JSON decode, + unexpected exceptions) return immediately. Transient failures + (network, 5xx, 429) retry up to ``MAX_RETRIES`` times with + exponential backoff (honoring ``Retry-After`` for 429 when + parseable). + """ + if not NEWSDATA_API_KEY: + return { + "status": "error", + "message": "NEWSDATA_API_KEY is not configured.", + "status_code": None, + } + + clean = _normalize_params(params) + logger.info("Newsdata.io GET /%s (%d params)", endpoint, len(clean)) + + last_envelope: dict[str, Any] = { + "status": "error", + "message": "Retry loop exhausted unexpectedly.", + "status_code": None, + } + for attempt in range(1, MAX_RETRIES + 1): + envelope, retryable = await _request_once(endpoint, clean) + if not retryable: + return envelope + last_envelope = envelope + if attempt >= MAX_RETRIES: + break + + retry_after = envelope.get("retry_after") + sleep_for = ( + float(retry_after) + if retry_after is not None + else _compute_backoff(attempt) + ) + logger.warning( + "Retryable failure on /%s (attempt %d/%d): %s; sleeping %.2fs", + endpoint, + attempt, + MAX_RETRIES, + envelope.get("message", ""), + sleep_for, + ) + await asyncio.sleep(sleep_for) + + return last_envelope diff --git a/src/newsdata_mcp/params.py b/src/newsdata_mcp/params.py new file mode 100644 index 0000000..6822296 --- /dev/null +++ b/src/newsdata_mcp/params.py @@ -0,0 +1,459 @@ +"""Pydantic `Annotated` parameter types reused across the MCP tools. + +These aliases bundle a regex/length/range plus a `description` and +`examples` so the resulting JSON Schema sent to MCP clients carries those +hints. The aliases live in one place; tool signatures import the ones +they need. +""" +from typing import Annotated, Literal, get_args + +from pydantic import Field + + +def _csv_pattern(*values: str) -> str: + choices = "|".join(values) + return rf"^(?:{choices})(?:,(?:{choices}))*$" + + +# NewsData documents 17 category codes in its public endpoint docs/blogs. +CATEGORY_CODE = Literal[ + "business", + "crime", + "domestic", + "education", + "entertainment", + "environment", + "food", + "health", + "lifestyle", + "other", + "politics", + "science", + "sports", + "technology", + "top", + "tourism", + "world", +] + +CATEGORY_FILTER = Annotated[ + str | list[CATEGORY_CODE], + Field( + pattern=_csv_pattern(*get_args(CATEGORY_CODE)), + description=( + "One or more NewsData category codes. " + "Accepts either a list (preferred): `['technology', 'science']`, " + "or a comma-separated string without spaces: `'technology,science'`. " + "A single value also works as a plain string: `'technology'`. " + "Allowed values: business, crime, domestic, education, entertainment, " + "environment, food, health, lifestyle, other, politics, science, " + "sports, technology, top, tourism, world." + ), + examples=["technology", ["technology", "science"], "business,world"], + ), +] + +PRIORITY_DOMAIN = Annotated[ + Literal["top", "medium", "low"], + Field( + description=( + "Filter results by source credibility tier. " + "`top` = highest credibility sources (top 10% of all sources). " + "`medium` = top 30% of sources. " + "`low` = top 50% of sources. " + "Omit for no filtering — returns articles from all sources. " + "Use `top` when accuracy and source quality matters most." + ), + examples=["top", "medium"], + ), +] + +SORT = Annotated[ + Literal["pubdateasc", "relevancy", "source"], + Field( + description=( + "Pass one sort mode only. Use `pubdateasc` for oldest first, " + "`relevancy` for query relevance, or `source` for source-priority order. " + "Omit this parameter to keep NewsData's default newest-first order." + ), + examples=["pubdateasc", "relevancy"], + ), +] + +SENTIMENT = Annotated[ + Literal["positive", "negative", "neutral"], + Field( + description=( + "Pass one sentiment filter only. Use `positive`, `negative`, or `neutral`." + ), + examples=["positive", "neutral"], + ), +] + +FLAG = Annotated[ + bool | Literal[0, 1], + Field( + description=( + "Binary filter flag. " + "Pass `True` (preferred) or `1` to require articles that HAVE this field. " + "Pass `False` or `0` to require articles that LACK this field. " + "Omit the parameter entirely to apply no filter. " + "Used for: `image`, `video`, `full_content`." + ), + examples=[True, False, 1, 0], + ), +] + +REMOVE_DUPLICATE = Annotated[ + bool | Literal[1], + Field( + description=( + "Ask NewsData to drop duplicate articles from the result set. " + "Pass `True` (preferred) or `1` to enable. " + "Pass `False` to disable — equivalent to omitting the parameter. " + "Do NOT pass `0` (the API rejects it; omit instead)." + ), + examples=[True, 1], + ), +] + +SIZE = Annotated[ + int, + Field( + ge=1, + le=50, + description=( + "Pass the number of articles to return in one page. Valid range is 1 to 50. " + "Free plans usually allow up to 10, while paid plans allow up to 50." + ), + examples=[10, 30], + ), +] + +ARTICLE_IDS = Annotated[ + str | list[str], + Field( + pattern=r"^[0-9a-f]{32}(?:,[0-9a-f]{32}){0,49}$", + description=( + "One to fifty NewsData `article_id` values (lowercase 32-char hex). " + "Accepts either a list (preferred): " + "`['668de67f2c32ce652104e7c4a5c9b517', '8c2cc0fdb87a3382876dca3448eb4cbc']`, " + "or a comma-separated string without spaces." + ), + examples=[ + "668de67f2c32ce652104e7c4a5c9b517", + ["668de67f2c32ce652104e7c4a5c9b517", "8c2cc0fdb87a3382876dca3448eb4cbc"], + "668de67f2c32ce652104e7c4a5c9b517,8c2cc0fdb87a3382876dca3448eb4cbc", + ], + ), +] + +QUERY = Annotated[ + str, + Field( + min_length=1, + max_length=512, + description=( + "Full-text search query for `q`, `qInTitle`, or `qInMeta`. " + "Use only ONE of these three in the same request. " + "Supports boolean operators: AND, OR, NOT. " + "Use quotes for exact phrases: '\"climate change\"'. " + "Use parentheses for grouping: '(bitcoin OR ethereum) AND regulation'. " + "Max 512 characters." + ), + examples=[ + "bitcoin", + "bitcoin AND ethereum", + '"climate change" NOT "fossil fuel"', + "(apple OR google) AND earnings", + ], + ), +] + +TIMEFRAME = Annotated[ + int | str, + Field( + description=( + "Time window for recent news. " + "Pass an integer for hours: `1` to `48` (e.g. `6` = last 6 hours). " + "Pass a string with suffix `m` for minutes: `1m` to `2880m` " + "(e.g. `90m` = last 90 minutes). " + "`48` and `2880m` are equivalent maximums. " + "Values outside these ranges will be rejected by the API." + ), + examples=[6, 24, 48, "90m", "2880m"], + ), +] + +INTERVAL = Annotated[ + Literal["hour", "day", "all"], + Field( + description=( + "Bucket size for count endpoints. " + "`hour` returns per-hour buckets, `day` returns per-day buckets, " + "`all` returns a single aggregate total (no buckets). " + "Used by `get_news_counts`, `get_crypto_counts`, `get_market_counts`." + ), + examples=["hour", "day", "all"], + ), +] + +COUNTRY_FILTER = Annotated[ + str | list[str], + Field( + pattern=r"^[a-z]{2}(?:,[a-z]{2}){0,9}$", + description=( + "One or more lowercase ISO 3166-1 alpha-2 country codes. " + "Accepts either a list (preferred): `['us', 'gb']`, " + "or a comma-separated string without spaces: `'us,gb'`. " + "Do not use country names or uppercase letters. Max 10 codes." + ), + examples=["us", ["us", "gb"], "in,au,jp"], + ), +] + +LANGUAGE_FILTER = Annotated[ + str | list[str], + Field( + pattern=r"^[a-z]{2}(?:,[a-z]{2}){0,9}$", + description=( + "One or more lowercase ISO 639-1 language codes. " + "Accepts either a list (preferred): `['en', 'fr']`, " + "or a comma-separated string without spaces: `'en,fr'`. " + "Do not use language names. Max 10 codes." + ), + examples=["en", ["en", "fr"], "hi,bn,ta"], + ), +] + +TAG_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=256, + pattern=r"^[^,]+(?:,[^,]+){0,9}$", + description=( + "One or more NewsData AI tags. " + "Accepts either a list (preferred): `['tourism', 'food']`, " + "or a comma-separated string without spaces: `'tourism,food'`. " + "Use the exact tag text expected by NewsData. Max 10 tags." + ), + examples=["food", ["tourism", "food"], "blockchain,markets"], + ), +] + +REGION_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=256, + pattern=r"^[^,]+(?:,[^,]+){0,9}$", + description=( + "One or more NewsData region names. " + "Accepts either a list (preferred): `['london-united kingdom', 'dubai-united arab emirates']`, " + "or a comma-separated string: `'london-united kingdom,dubai-united arab emirates'`. " + "Use city-country style values. Max 10 regions." + ), + examples=[ + "new york-united states of america", + ["london-united kingdom", "dubai-united arab emirates"], + "london-united kingdom,dubai-united arab emirates", + ], + ), +] + +DOMAIN_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9.-]+(?:,[A-Za-z0-9.-]+){0,9}$", + description=( + "One or more domain identifiers (short source IDs or hostnames). " + "Accepts either a list (preferred): `['reuters.com', 'bbc.com']`, " + "or a comma-separated string: `'reuters.com,bbc.com'`. " + "Do not include `http://` or `https://`. Max 10 entries." + ), + examples=["bbc", ["bbc", "coindesk"], "reuters.com,bbc.com"], + ), +] + +DOMAIN_URL_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=255, + pattern=r"^[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:,[A-Za-z0-9.-]+\.[A-Za-z]{2,}){0,9}$", + description=( + "One or more full domain hosts (hostnames only, not URLs). " + "Accepts either a list (preferred): `['bbc.com', 'reuters.com']`, " + "or a comma-separated string: `'bbc.com,reuters.com'`. " + "Use hostnames like `bbc.com`, not full article URLs. Max 10 hosts." + ), + examples=["bbc.com", ["bbc.com", "reuters.com"], "bbc.com,reuters.com"], + ), +] + +EXCLUDE_FIELD_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=256, + pattern=r"^[A-Za-z_]+(?:,[A-Za-z_]+){0,27}$", + description=( + "One or more response field names to exclude from the result. " + "Accepts either a list (preferred): `['pubdate', 'imageurl']`, " + "or a comma-separated string: `'pubdate,imageurl'`. " + "Use the field names expected by NewsData, e.g. `pubdate`, " + "`imageurl`, `content`, `source_id`. Max 28 fields." + ), + examples=["pubdate", ["pubdate", "imageurl"], "content,source_id"], + ), +] + +TIMEZONE = Annotated[ + str, + Field( + min_length=3, + max_length=64, + pattern=r"^[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+$", + description=( + "Pass an IANA timezone name. Use values like `Asia/Dubai` or " + "`America/New_York`." + ), + examples=["Asia/Dubai", "America/New_York"], + ), +] + +PAGE = Annotated[ + str, + Field( + description=( + "Pagination cursor for fetching the next page of results. " + "Only pass this if a previous API response returned a `nextPage` field. " + "On first request, omit this parameter entirely. " + "Copy the token exactly — do not modify, encode, or guess it." + ), + examples=["17349543216784a12c9f0f6fbe7c1234"], + ), +] + +URL = Annotated[ + str, + Field( + pattern=r"^https?://\S+$", + description=( + "Pass a full absolute article URL starting with `http://` or `https://`." + ), + examples=["https://newsdata.io/blog/multiple-api-key-newsdata-io"], + ), +] + +DATE_OR_DATETIME = Annotated[ + str, + Field( + pattern=r"^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}:\d{2})?$", + description=( + "A date as `YYYY-MM-DD` (e.g. `2025-01-01`) or as " + "`YYYY-MM-DD HH:MM:SS` (e.g. `2025-01-01 06:12:45`) when " + "you need sub-day precision. Both forms are accepted on " + "every endpoint that takes a date." + ), + examples=["2025-01-01", "2025-01-01 06:12:45"], + ), +] + +COIN_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+){0,9}$", + description=( + "One or more crypto coin symbols (ticker-style). " + "Accepts either a list (preferred): `['btc', 'eth']`, " + "or a comma-separated string: `'btc,eth'`. Max 10 coins." + ), + examples=["btc", ["btc", "eth"], "sol,ada,xrp"], + ), +] + +SYMBOL_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=128, + pattern=r"^[A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+){0,9}$", + description=( + "One or more market symbols or stock tickers. " + "Accepts either a list (preferred): `['AAPL', 'MSFT']`, " + "or a comma-separated string: `'AAPL,MSFT'`. Max 10 symbols." + ), + examples=["AAPL", ["AAPL", "MSFT"], "TSLA,NVDA,AMZN"], + ), +] + +ORGANIZATION_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=256, + pattern=r"^[^,]+(?:,[^,]+){0,9}$", + description=( + "One or more organization names. " + "Accepts either a list (preferred): `['tesla', 'apple']`, " + "or a comma-separated string: `'tesla,apple'`. " + "Use plain organization names. Max 10 entries." + ), + examples=["uber", ["tesla", "apple"], "tesla,microsoft,google"], + ), +] + +CREATOR_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=256, + pattern=r"^[^,]+(?:,[^,]+){0,9}$", + description=( + "One or more author/byline names. " + "Accepts either a list (preferred): `['John Smith', 'Jane Doe']`, " + "or a comma-separated string: `'John Smith,Jane Doe'`. " + "Use the exact byline text expected by NewsData. Max 10 names." + ), + examples=["john smith", ["john smith", "jane doe"], "ana lopez,bao chen"], + ), +] + +DATATYPE_FILTER = Annotated[ + str | list[str], + Field( + min_length=1, + max_length=64, + pattern=r"^[^,]+(?:,[^,]+){0,9}$", + description=( + "Filter by content type. " + "Accepts either a list (preferred): `['article', 'video']`, " + "or a comma-separated string: `'article,video'`. " + "Use the exact datatype values supported by NewsData (e.g. `article`). " + "Max 10 entries." + ), + examples=["article", ["article", "video"], "article,image"], + ), +] + +SENTIMENT_SCORE = Annotated[ + int, + Field( + ge=0, + le=100, + description=( + "Minimum confidence percentage (0–100) for the chosen `sentiment` label. " + "For example, with `sentiment='positive'` and `sentiment_score=50`, only " + "articles whose positive-sentiment confidence is at least 50 are returned. " + "REQUIRES `sentiment` to also be set; passing `sentiment_score` without " + "`sentiment` returns Error before any API call." + ), + examples=[50, 70, 90], + ), +] diff --git a/src/newsdata_mcp/server.py b/src/newsdata_mcp/server.py index d67bc42..09a45c3 100644 --- a/src/newsdata_mcp/server.py +++ b/src/newsdata_mcp/server.py @@ -1,24 +1,41 @@ -import sys +"""CLI entry point for the NewsData MCP server. + +Parses ``--transport`` (``stdio`` or ``streamable-http``), ``--host``, +``--port``, and ``--version``, then hands off to ``FastMCP.run``. The +side-effect ``from . import tools`` triggers every `@mcp.tool()` +decorator so all eight tools are registered before the server starts. +""" import argparse import logging +import sys -from . import client -from .app import mcp +from . import ( + __version__, + tools, # noqa: F401 — side-effect: registers @mcp.tool() handlers +) +from ._mcp import mcp logging.basicConfig(level=logging.INFO, stream=sys.stderr) -def main(): - parser = argparse.ArgumentParser() + +def main() -> None: + parser = argparse.ArgumentParser(prog="newsdata-mcp") + parser.add_argument( + "--version", + action="version", + version=f"newsdata-mcp {__version__}", + ) parser.add_argument("--transport", choices=["stdio", "streamable-http"], default="stdio") parser.add_argument("--host", default=mcp.settings.host) parser.add_argument("--port", type=int, default=mcp.settings.port) args = parser.parse_args() - + if args.transport == "streamable-http": mcp.settings.host = args.host mcp.settings.port = args.port - + mcp.run(transport=args.transport) + if __name__ == "__main__": main() diff --git a/src/newsdata_mcp/settings.py b/src/newsdata_mcp/settings.py new file mode 100644 index 0000000..362ec8b --- /dev/null +++ b/src/newsdata_mcp/settings.py @@ -0,0 +1,54 @@ +"""Runtime configuration read from environment variables. + +Loaded once at import time. Restart the server after changing env vars. +""" +import os +import warnings + +from dotenv import load_dotenv + +load_dotenv() + +NEWSDATA_API_KEY = os.getenv("NEWSDATA_API_KEY") +NEWSDATA_BASE_URL = os.getenv("NEWSDATA_BASE_URL", "https://newsdata.io/api/1") + +try: + REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30")) +except (ValueError, TypeError): + warnings.warn( + "REQUEST_TIMEOUT must be an integer; falling back to 30", + stacklevel=2, + ) + REQUEST_TIMEOUT = 30 + +# Retry policy (network errors, 5xx, 429). Defaults sleep about a minute +# total across all attempts (2s → 4s → 8s → 16s → 32s, capped at 60s). +try: + MAX_RETRIES = int(os.getenv("NEWSDATA_MAX_RETRIES", "5")) +except (ValueError, TypeError): + warnings.warn( + "NEWSDATA_MAX_RETRIES must be an integer; falling back to 5", + stacklevel=2, + ) + MAX_RETRIES = 5 + +try: + RETRY_BACKOFF = float(os.getenv("NEWSDATA_RETRY_BACKOFF", "2.0")) +except (ValueError, TypeError): + warnings.warn( + "NEWSDATA_RETRY_BACKOFF must be a number; falling back to 2.0", + stacklevel=2, + ) + RETRY_BACKOFF = 2.0 + +try: + RETRY_BACKOFF_MAX = float(os.getenv("NEWSDATA_RETRY_BACKOFF_MAX", "60.0")) +except (ValueError, TypeError): + warnings.warn( + "NEWSDATA_RETRY_BACKOFF_MAX must be a number; falling back to 60.0", + stacklevel=2, + ) + RETRY_BACKOFF_MAX = 60.0 + +if not NEWSDATA_API_KEY: + warnings.warn("NEWSDATA_API_KEY is not set", stacklevel=2) diff --git a/src/newsdata_mcp/tools/__init__.py b/src/newsdata_mcp/tools/__init__.py new file mode 100644 index 0000000..c862491 --- /dev/null +++ b/src/newsdata_mcp/tools/__init__.py @@ -0,0 +1,29 @@ +"""Tool registrations for the NewsData MCP server. + +Importing this package as a side-effect registers every `@mcp.tool()` +decorated function on the shared `mcp` instance from `_mcp.py`. The +entry point (`server.py`) does `from . import tools` for exactly that +reason; the individual modules are not meant to be imported by user +code. +""" +from . import ( # noqa: F401 — registers tools + archive, + count, + crypto, + crypto_count, + latest, + market, + market_count, + sources, +) + +__all__ = [ + "archive", + "count", + "crypto", + "crypto_count", + "latest", + "market", + "market_count", + "sources", +] diff --git a/src/newsdata_mcp/tools/archive.py b/src/newsdata_mcp/tools/archive.py new file mode 100644 index 0000000..bb7401f --- /dev/null +++ b/src/newsdata_mcp/tools/archive.py @@ -0,0 +1,149 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_articles +from ..http import fetch +from ..params import ( + ARTICLE_IDS, + CATEGORY_FILTER, + COUNTRY_FILTER, + CREATOR_FILTER, + DATATYPE_FILTER, + DATE_OR_DATETIME, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + EXCLUDE_FIELD_FILTER, + FLAG, + LANGUAGE_FILTER, + ORGANIZATION_FILTER, + PAGE, + PRIORITY_DOMAIN, + QUERY, + REGION_FILTER, + REMOVE_DUPLICATE, + SENTIMENT, + SENTIMENT_SCORE, + SIZE, + SORT, + TAG_FILTER, + TIMEZONE, + URL, +) +from ..validators import ( + check_mutex_groups, + check_sentiment_score_requires_sentiment, +) + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_archive_news( + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + country: COUNTRY_FILTER | None = None, + exclude_country: COUNTRY_FILTER | None = None, + category: CATEGORY_FILTER | None = None, + exclude_category: CATEGORY_FILTER | None = None, + language: LANGUAGE_FILTER | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + domain: DOMAIN_FILTER | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + size: SIZE | None = None, + timezone: TIMEZONE | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + page: PAGE | None = None, + from_date: DATE_OR_DATETIME | None = None, + to_date: DATE_OR_DATETIME | None = None, + excludefield: EXCLUDE_FIELD_FILTER | None = None, + article_id: ARTICLE_IDS | None = None, + url: URL | None = None, + sort: SORT | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, + sentiment: SENTIMENT | None = None, + creator: CREATOR_FILTER | None = None, + datatype: DATATYPE_FILTER | None = None, + sentiment_score: SENTIMENT_SCORE | None = None, + tag: TAG_FILTER | None = None, + region: REGION_FILTER | None = None, + organization: ORGANIZATION_FILTER | None = None, +) -> str: + """ + Use this tool to search HISTORICAL news articles older than 48 hours. + For real-time/recent news, use `get_latest_news` instead. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `country` with `exclude_country`. + - Do NOT combine `category` with `exclude_category`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + - `sentiment_score` requires `sentiment` to also be set. + + Other guidance: + - Use `from_date` and/or `to_date` to define the historical date range. + - Dates can be `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` for precision. + - `timeframe` is NOT available on this endpoint — use date range instead. + - `article_id` or `url` can fetch a single specific historical article. + - `creator` filters by author/byline name(s). + - `datatype` filters by content type (e.g. "article"). + - `sentiment_score` is a minimum confidence percentage (0–100) for the chosen + `sentiment` label, e.g. `sentiment="negative", sentiment_score=70` keeps + only articles whose negative-sentiment score is at least 70. + - `tag` filters by AI-generated topic tags (e.g. "blockchain", "climate"). + - `region` filters by city-country pairs (e.g. "delhi-india"). + - `organization` filters by company/org name mentions in articles. + + Examples: + - `q="(ukraine war) AND (russia OR putin)", from_date="2024-01-01", to_date="2024-01-31", language="en"` + - `category="politics", country="us", from_date="2024-11-01", to_date="2024-11-30"` + - `q="IPO", from_date="2025-01-01 00:00:00", sort="relevancy"` + - `sentiment="negative", sentiment_score=70, from_date="2024-01-01"` + """ + error = ( + check_mutex_groups(locals()) + or check_sentiment_score_requires_sentiment(locals()) + ) + if error: + return f"Error: {error}" + + data = await fetch( + "archive", + { + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "country": country, + "excludecountry": exclude_country, + "category": category, + "excludecategory": exclude_category, + "language": language, + "excludelanguage": exclude_language, + "domain": domain, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "size": size, + "timezone": timezone, + "full_content": full_content, + "image": image, + "video": video, + "prioritydomain": priority_domain, + "page": page, + "from_date": from_date, + "to_date": to_date, + "excludefield": excludefield, + "id": article_id, + "url": url, + "sort": sort, + "removeduplicate": removeduplicate, + "sentiment": sentiment, + "creator": creator, + "datatype": datatype, + "sentiment_score": sentiment_score, + "tag": tag, + "region": region, + "organization": organization, + }, + ) + return format_articles(data, "archive") diff --git a/src/newsdata_mcp/tools/count.py b/src/newsdata_mcp/tools/count.py new file mode 100644 index 0000000..fc118fc --- /dev/null +++ b/src/newsdata_mcp/tools/count.py @@ -0,0 +1,144 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_counts +from ..http import fetch +from ..params import ( + CATEGORY_FILTER, + COUNTRY_FILTER, + CREATOR_FILTER, + DATATYPE_FILTER, + DATE_OR_DATETIME, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + FLAG, + INTERVAL, + LANGUAGE_FILTER, + ORGANIZATION_FILTER, + PAGE, + PRIORITY_DOMAIN, + QUERY, + REGION_FILTER, + REMOVE_DUPLICATE, + SENTIMENT, + SENTIMENT_SCORE, + SIZE, + SORT, + TAG_FILTER, +) +from ..validators import ( + check_mutex_groups, + check_sentiment_score_requires_sentiment, +) + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_news_counts( + from_date: DATE_OR_DATETIME, + to_date: DATE_OR_DATETIME, + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + country: COUNTRY_FILTER | None = None, + exclude_country: COUNTRY_FILTER | None = None, + category: CATEGORY_FILTER | None = None, + exclude_category: CATEGORY_FILTER | None = None, + language: LANGUAGE_FILTER | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + domain: DOMAIN_FILTER | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + page: PAGE | None = None, + size: SIZE | None = None, + sort: SORT | None = None, + interval: INTERVAL | None = None, + tag: TAG_FILTER | None = None, + sentiment: SENTIMENT | None = None, + sentiment_score: SENTIMENT_SCORE | None = None, + region: REGION_FILTER | None = None, + organization: ORGANIZATION_FILTER | None = None, + creator: CREATOR_FILTER | None = None, + datatype: DATATYPE_FILTER | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, +) -> str: + """ + Use this tool to fetch AGGREGATE ARTICLE COUNTS over a date range. + With `interval="hour"` or `"day"` returns per-bucket counts; with + `interval="all"` (or when `interval` is omitted) returns a single + aggregate count for the range. + + For actual article content, use `get_archive_news` (older than 48h), + `get_latest_news` (last 48h), `get_crypto_news`, or `get_market_news` + instead. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `country` with `exclude_country`. + - Do NOT combine `category` with `exclude_category`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + - `sentiment_score` requires `sentiment` to also be set. + + Other guidance: + - `from_date` and `to_date` are REQUIRED. + - Date format: `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`. + - `interval` chooses the bucket size: `hour` (per-hour buckets), `day` + (per-day buckets), or `all` (single aggregate total, no buckets). + - `sentiment_score` is a minimum confidence percentage (0–100) for the chosen + `sentiment` label, e.g. `sentiment="positive", sentiment_score=50` filters + the count to articles whose positive-sentiment score is at least 50. + - Each returned bucket has its own count, e.g. `{"dateTime": "...", "count": N}`. + + Examples: + - `from_date="2024-01-01", to_date="2024-01-31", q="bitcoin", interval="day"` + → daily counts of articles mentioning bitcoin in January. + - `from_date="2024-11-01", to_date="2024-11-30", interval="hour", country="us"` + → hourly counts of US articles in November. + - `from_date="2024-01-01", to_date="2024-12-31", interval="all", category="technology"` + → a single aggregate count of tech articles for the year. + """ + error = ( + check_mutex_groups(locals()) + or check_sentiment_score_requires_sentiment(locals()) + ) + if error: + return f"Error: {error}" + + data = await fetch( + "count", + { + "from_date": from_date, + "to_date": to_date, + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "country": country, + "excludecountry": exclude_country, + "category": category, + "excludecategory": exclude_category, + "language": language, + "excludelanguage": exclude_language, + "domain": domain, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "full_content": full_content, + "image": image, + "video": video, + "prioritydomain": priority_domain, + "page": page, + "size": size, + "sort": sort, + "interval": interval, + "tag": tag, + "sentiment": sentiment, + "sentiment_score": sentiment_score, + "region": region, + "organization": organization, + "creator": creator, + "datatype": datatype, + "removeduplicate": removeduplicate, + }, + ) + return format_counts(data, "count") diff --git a/src/newsdata_mcp/tools/crypto.py b/src/newsdata_mcp/tools/crypto.py new file mode 100644 index 0000000..ceff539 --- /dev/null +++ b/src/newsdata_mcp/tools/crypto.py @@ -0,0 +1,115 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_articles +from ..http import fetch +from ..params import ( + ARTICLE_IDS, + COIN_FILTER, + DATE_OR_DATETIME, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + EXCLUDE_FIELD_FILTER, + FLAG, + LANGUAGE_FILTER, + PAGE, + PRIORITY_DOMAIN, + QUERY, + REMOVE_DUPLICATE, + SENTIMENT, + SIZE, + SORT, + TAG_FILTER, + TIMEFRAME, + TIMEZONE, + URL, +) +from ..validators import check_mutex_groups + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_crypto_news( + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + language: LANGUAGE_FILTER | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + domain: DOMAIN_FILTER | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + timeframe: TIMEFRAME | None = None, + size: SIZE | None = None, + timezone: TIMEZONE | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + page: PAGE | None = None, + tag: TAG_FILTER | None = None, + sentiment: SENTIMENT | None = None, + coin: COIN_FILTER | None = None, + excludefield: EXCLUDE_FIELD_FILTER | None = None, + from_date: DATE_OR_DATETIME | None = None, + to_date: DATE_OR_DATETIME | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, + article_id: ARTICLE_IDS | None = None, + url: URL | None = None, + sort: SORT | None = None, +) -> str: + """ + Use this tool for CRYPTOCURRENCY news only. It searches a crypto-focused article index. + For general financial/stock news, use `get_market_news` instead. + For general news, use `get_latest_news`. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + + Other guidance: + - Use `coin` to filter by crypto ticker symbols (e.g. `btc`, `eth,sol`). + - Use `q` for keyword search on top of coin filter, or alone if no specific coin. + - `coin` and `q` can be combined: `coin="btc", q="ETF"`. + - `country` and `category` are NOT available on this endpoint. + - Use `timeframe` OR `from_date`/`to_date`, not both. + - `sentiment` is useful here: `positive` for bullish news, `negative` for bearish. + + Examples: + - `coin="btc,eth", language="en", sentiment="positive"` + - `q="ETF approval", coin="btc", timeframe="24"` + - `coin="sol", from_date="2025-01-01", to_date="2025-01-31"` + """ + error = check_mutex_groups(locals()) + if error: + return f"Error: {error}" + + data = await fetch( + "crypto", + { + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "language": language, + "excludelanguage": exclude_language, + "domain": domain, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "timeframe": timeframe, + "size": size, + "timezone": timezone, + "full_content": full_content, + "image": image, + "video": video, + "prioritydomain": priority_domain, + "page": page, + "tag": tag, + "sentiment": sentiment, + "coin": coin, + "excludefield": excludefield, + "from_date": from_date, + "to_date": to_date, + "removeduplicate": removeduplicate, + "id": article_id, + "url": url, + "sort": sort, + }, + ) + return format_articles(data, "crypto") diff --git a/src/newsdata_mcp/tools/crypto_count.py b/src/newsdata_mcp/tools/crypto_count.py new file mode 100644 index 0000000..68d66cb --- /dev/null +++ b/src/newsdata_mcp/tools/crypto_count.py @@ -0,0 +1,109 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_counts +from ..http import fetch +from ..params import ( + COIN_FILTER, + DATE_OR_DATETIME, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + FLAG, + INTERVAL, + LANGUAGE_FILTER, + PAGE, + PRIORITY_DOMAIN, + QUERY, + REMOVE_DUPLICATE, + SENTIMENT, + SIZE, + SORT, + TAG_FILTER, +) +from ..validators import check_mutex_groups + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_crypto_counts( + from_date: DATE_OR_DATETIME, + to_date: DATE_OR_DATETIME, + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + language: LANGUAGE_FILTER | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + coin: COIN_FILTER | None = None, + domain: DOMAIN_FILTER | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + page: PAGE | None = None, + sentiment: SENTIMENT | None = None, + size: SIZE | None = None, + sort: SORT | None = None, + tag: TAG_FILTER | None = None, + interval: INTERVAL | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, +) -> str: + """ + Use this tool to fetch AGGREGATE CRYPTO ARTICLE COUNTS over a date range. + With `interval="hour"` or `"day"` returns per-bucket counts; with + `interval="all"` (or when `interval` is omitted) returns a single + aggregate count for the range. + + For actual crypto article content, use `get_crypto_news` instead. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + + Other guidance: + - `from_date` and `to_date` are REQUIRED. + - Date format: `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`. + - `country` and `category` are NOT available on this endpoint. + - `interval` chooses the bucket size: `hour` (per-hour buckets), `day` + (per-day buckets), or `all` (single aggregate total, no buckets). + - Use `coin` to narrow to specific coin ticker(s). + + Examples: + - `from_date="2024-01-01", to_date="2024-01-31", coin="btc", interval="day"` + → daily BTC counts. + - `from_date="2024-11-01", to_date="2024-11-30", interval="hour", coin=["btc", "eth"]` + → hourly BTC/ETH counts. + - `from_date="2024-01-01", to_date="2024-12-31", interval="all", sentiment="positive"` + → a single aggregate count of bullish crypto articles for the year. + """ + error = check_mutex_groups(locals()) + if error: + return f"Error: {error}" + + data = await fetch( + "crypto/count", + { + "from_date": from_date, + "to_date": to_date, + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "language": language, + "excludelanguage": exclude_language, + "coin": coin, + "domain": domain, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "full_content": full_content, + "image": image, + "video": video, + "prioritydomain": priority_domain, + "page": page, + "sentiment": sentiment, + "size": size, + "sort": sort, + "tag": tag, + "interval": interval, + "removeduplicate": removeduplicate, + }, + ) + return format_counts(data, "crypto/count") diff --git a/src/newsdata_mcp/tools/latest.py b/src/newsdata_mcp/tools/latest.py new file mode 100644 index 0000000..5a8ffdc --- /dev/null +++ b/src/newsdata_mcp/tools/latest.py @@ -0,0 +1,149 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_articles +from ..http import fetch +from ..params import ( + ARTICLE_IDS, + CATEGORY_FILTER, + COUNTRY_FILTER, + CREATOR_FILTER, + DATATYPE_FILTER, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + EXCLUDE_FIELD_FILTER, + FLAG, + LANGUAGE_FILTER, + ORGANIZATION_FILTER, + PAGE, + PRIORITY_DOMAIN, + QUERY, + REGION_FILTER, + REMOVE_DUPLICATE, + SENTIMENT, + SENTIMENT_SCORE, + SIZE, + SORT, + TAG_FILTER, + TIMEFRAME, + TIMEZONE, + URL, +) +from ..validators import ( + check_mutex_groups, + check_sentiment_score_requires_sentiment, +) + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_latest_news( + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + country: COUNTRY_FILTER | None = None, + exclude_country: COUNTRY_FILTER | None = None, + category: CATEGORY_FILTER | None = None, + exclude_category: CATEGORY_FILTER | None = None, + language: LANGUAGE_FILTER | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + domain: DOMAIN_FILTER | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + timeframe: TIMEFRAME | None = None, + size: SIZE | None = None, + timezone: TIMEZONE | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + page: PAGE | None = None, + tag: TAG_FILTER | None = None, + sentiment: SENTIMENT | None = None, + region: REGION_FILTER | None = None, + excludefield: EXCLUDE_FIELD_FILTER | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, + article_id: ARTICLE_IDS | None = None, + organization: ORGANIZATION_FILTER | None = None, + url: URL | None = None, + sort: SORT | None = None, + creator: CREATOR_FILTER | None = None, + datatype: DATATYPE_FILTER | None = None, + sentiment_score: SENTIMENT_SCORE | None = None, +) -> str: + """ + Use this tool to fetch REAL-TIME or RECENT news articles (last 48 hours max). + For older articles, use `get_archive_news` instead. + For crypto-specific news, use `get_crypto_news`. + For stock/market news, use `get_market_news`. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `country` with `exclude_country`. + - Do NOT combine `category` with `exclude_category`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + - `sentiment_score` requires `sentiment` to also be set. + + Other guidance: + - `q` searches full content. `q_in_title` restricts to title only. `q_in_meta` searches metadata. + - Use `timeframe` to restrict to last N hours/minutes. Omit for latest articles with no time filter. + - `article_id` and `url` are for fetching one specific known article, not for search. + - `tag` filters by AI-generated topic tags (e.g. "blockchain", "climate"). + - `region` filters by city-country pairs (e.g. "delhi-india"). + - `organization` filters by company/org name mentions in articles. + - `creator` filters by author/byline name(s). + - `datatype` filters by content type (e.g. "article"). + - `sentiment_score` is a minimum confidence percentage (0–100) for the chosen + `sentiment` label, e.g. `sentiment="positive", sentiment_score=50` keeps + only articles whose positive-sentiment score is at least 50. + + Examples: + - `q="(bitcoin OR ethereum) AND regulation", country="us", language="en", size=10` + - `category="technology", priority_domain="top", sort="relevancy"` + - `q="apple earnings", organization="apple", timeframe="24"` + - `category="sports", country="in", language="hi"` + - `sentiment="positive", sentiment_score=70, q="elections"` + """ + error = ( + check_mutex_groups(locals()) + or check_sentiment_score_requires_sentiment(locals()) + ) + if error: + return f"Error: {error}" + + data = await fetch( + "latest", + { + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "country": country, + "excludecountry": exclude_country, + "category": category, + "excludecategory": exclude_category, + "language": language, + "excludelanguage": exclude_language, + "domain": domain, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "timeframe": timeframe, + "size": size, + "timezone": timezone, + "full_content": full_content, + "image": image, + "video": video, + "prioritydomain": priority_domain, + "page": page, + "tag": tag, + "sentiment": sentiment, + "region": region, + "excludefield": excludefield, + "removeduplicate": removeduplicate, + "id": article_id, + "organization": organization, + "url": url, + "sort": sort, + "creator": creator, + "datatype": datatype, + "sentiment_score": sentiment_score, + }, + ) + return format_articles(data, "latest") diff --git a/src/newsdata_mcp/tools/market.py b/src/newsdata_mcp/tools/market.py new file mode 100644 index 0000000..4cf0ccd --- /dev/null +++ b/src/newsdata_mcp/tools/market.py @@ -0,0 +1,146 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_articles +from ..http import fetch +from ..params import ( + ARTICLE_IDS, + COUNTRY_FILTER, + CREATOR_FILTER, + DATATYPE_FILTER, + DATE_OR_DATETIME, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + EXCLUDE_FIELD_FILTER, + FLAG, + LANGUAGE_FILTER, + ORGANIZATION_FILTER, + PAGE, + PRIORITY_DOMAIN, + QUERY, + REMOVE_DUPLICATE, + SENTIMENT, + SENTIMENT_SCORE, + SIZE, + SORT, + SYMBOL_FILTER, + TAG_FILTER, + TIMEFRAME, + TIMEZONE, + URL, +) +from ..validators import ( + check_mutex_groups, + check_sentiment_score_requires_sentiment, +) + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_market_news( + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + from_date: DATE_OR_DATETIME | None = None, + to_date: DATE_OR_DATETIME | None = None, + domain: DOMAIN_FILTER | None = None, + language: LANGUAGE_FILTER | None = None, + page: PAGE | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + timeframe: TIMEFRAME | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + timezone: TIMEZONE | None = None, + size: SIZE | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + tag: TAG_FILTER | None = None, + sentiment: SENTIMENT | None = None, + article_id: ARTICLE_IDS | None = None, + excludefield: EXCLUDE_FIELD_FILTER | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + organization: ORGANIZATION_FILTER | None = None, + url: URL | None = None, + sort: SORT | None = None, + symbol: SYMBOL_FILTER | None = None, + country: COUNTRY_FILTER | None = None, + exclude_country: COUNTRY_FILTER | None = None, + creator: CREATOR_FILTER | None = None, + datatype: DATATYPE_FILTER | None = None, + sentiment_score: SENTIMENT_SCORE | None = None, +) -> str: + """ + Use this tool for STOCK MARKET and FINANCIAL news. + For crypto news, use `get_crypto_news` instead. + For general news, use `get_latest_news`. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `country` with `exclude_country`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + - `sentiment_score` requires `sentiment` to also be set. + + Other guidance: + - Use `symbol` for stock/market tickers (e.g. `AAPL`, `TSLA,NVDA`). + - Use `organization` for company name filtering (e.g. `tesla,apple`). + - `symbol` and `organization` can be combined for precision. + - `category` is NOT available on this endpoint. + - Use `timeframe` OR `from_date`/`to_date`, not both. + - `creator` filters by author/byline name(s). + - `datatype` filters by content type (e.g. "article"). + - `sentiment_score` is a minimum confidence percentage (0–100) for the chosen + `sentiment` label, e.g. `sentiment="positive", sentiment_score=80` keeps + only articles whose positive-sentiment score is at least 80. + + Examples: + - `symbol="AAPL,MSFT", language="en", sort="relevancy"` + - `organization="tesla,nvidia", timeframe="48", sentiment="positive"` + - `q="earnings beat", symbol="NVDA", from_date="2025-01-01"` + - `country="us", priority_domain="top", sort="pubdateasc"` + - `sentiment="positive", sentiment_score=80, symbol="NVDA"` + """ + error = ( + check_mutex_groups(locals()) + or check_sentiment_score_requires_sentiment(locals()) + ) + if error: + return f"Error: {error}" + + data = await fetch( + "market", + { + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "from_date": from_date, + "to_date": to_date, + "domain": domain, + "language": language, + "page": page, + "full_content": full_content, + "image": image, + "video": video, + "timeframe": timeframe, + "prioritydomain": priority_domain, + "timezone": timezone, + "size": size, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "tag": tag, + "sentiment": sentiment, + "id": article_id, + "excludefield": excludefield, + "removeduplicate": removeduplicate, + "excludelanguage": exclude_language, + "organization": organization, + "url": url, + "sort": sort, + "symbol": symbol, + "country": country, + "excludecountry": exclude_country, + "creator": creator, + "datatype": datatype, + "sentiment_score": sentiment_score, + }, + ) + return format_articles(data, "market") diff --git a/src/newsdata_mcp/tools/market_count.py b/src/newsdata_mcp/tools/market_count.py new file mode 100644 index 0000000..7dedc96 --- /dev/null +++ b/src/newsdata_mcp/tools/market_count.py @@ -0,0 +1,137 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_counts +from ..http import fetch +from ..params import ( + COUNTRY_FILTER, + CREATOR_FILTER, + DATATYPE_FILTER, + DATE_OR_DATETIME, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + FLAG, + INTERVAL, + LANGUAGE_FILTER, + ORGANIZATION_FILTER, + PAGE, + PRIORITY_DOMAIN, + QUERY, + REMOVE_DUPLICATE, + SENTIMENT, + SENTIMENT_SCORE, + SIZE, + SORT, + SYMBOL_FILTER, + TAG_FILTER, +) +from ..validators import ( + check_mutex_groups, + check_sentiment_score_requires_sentiment, +) + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_market_counts( + from_date: DATE_OR_DATETIME, + to_date: DATE_OR_DATETIME, + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + country: COUNTRY_FILTER | None = None, + exclude_country: COUNTRY_FILTER | None = None, + domain: DOMAIN_FILTER | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + language: LANGUAGE_FILTER | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + organization: ORGANIZATION_FILTER | None = None, + symbol: SYMBOL_FILTER | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + page: PAGE | None = None, + sentiment: SENTIMENT | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, + size: SIZE | None = None, + sort: SORT | None = None, + tag: TAG_FILTER | None = None, + interval: INTERVAL | None = None, + creator: CREATOR_FILTER | None = None, + datatype: DATATYPE_FILTER | None = None, + sentiment_score: SENTIMENT_SCORE | None = None, +) -> str: + """ + Use this tool to fetch AGGREGATE MARKET/FINANCIAL ARTICLE COUNTS over + a date range. With `interval="hour"` or `"day"` returns per-bucket + counts; with `interval="all"` (or when `interval` is omitted) returns + a single aggregate count for the range. + + For actual market article content, use `get_market_news` instead. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `country` with `exclude_country`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + - `sentiment_score` requires `sentiment` to also be set. + + Other guidance: + - `from_date` and `to_date` are REQUIRED. + - Date format: `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`. + - `category` is NOT available on this endpoint. + - `interval` chooses the bucket size: `hour` (per-hour buckets), `day` + (per-day buckets), or `all` (single aggregate total, no buckets). + - `sentiment_score` is a minimum confidence percentage (0–100) for the chosen + `sentiment` label, e.g. `sentiment="positive", sentiment_score=70` filters + the count to articles whose positive-sentiment score is at least 70. + - Use `symbol` and/or `organization` to narrow to specific tickers / companies. + + Examples: + - `from_date="2024-01-01", to_date="2024-01-31", symbol="AAPL", interval="day"` + → daily Apple counts in January. + - `from_date="2024-01-01", to_date="2024-03-31", interval="hour", organization=["tesla", "nvidia"]` + → hourly Tesla+NVIDIA counts for Q1. + - `from_date="2024-01-01", to_date="2024-12-31", interval="all", country="us", sentiment="positive"` + → a single aggregate count of bullish US market articles for the year. + """ + error = ( + check_mutex_groups(locals()) + or check_sentiment_score_requires_sentiment(locals()) + ) + if error: + return f"Error: {error}" + + data = await fetch( + "market/count", + { + "from_date": from_date, + "to_date": to_date, + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "country": country, + "excludecountry": exclude_country, + "domain": domain, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "language": language, + "excludelanguage": exclude_language, + "full_content": full_content, + "image": image, + "video": video, + "organization": organization, + "symbol": symbol, + "prioritydomain": priority_domain, + "page": page, + "sentiment": sentiment, + "removeduplicate": removeduplicate, + "size": size, + "sort": sort, + "tag": tag, + "interval": interval, + "creator": creator, + "datatype": datatype, + "sentiment_score": sentiment_score, + }, + ) + return format_counts(data, "market/count") diff --git a/src/newsdata_mcp/tools/sources.py b/src/newsdata_mcp/tools/sources.py new file mode 100644 index 0000000..8a153d0 --- /dev/null +++ b/src/newsdata_mcp/tools/sources.py @@ -0,0 +1,56 @@ +from .._mcp import READ_ONLY_TOOL, mcp +from ..formatters import format_sources +from ..http import fetch +from ..params import ( + CATEGORY_FILTER, + COUNTRY_FILTER, + DOMAIN_URL_FILTER, + LANGUAGE_FILTER, + PRIORITY_DOMAIN, +) +from ..validators import check_mutex_groups + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def get_news_sources( + country: COUNTRY_FILTER | None = None, + category: CATEGORY_FILTER | None = None, + language: LANGUAGE_FILTER | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, +) -> str: + """ + Use this tool to DISCOVER available news sources, not to fetch articles. + Use this when the user wants to: + - Find which sources are available for a country or language. + - Get source IDs to use in `domain` filter in other tools. + - Explore what categories a source covers. + + Strict rules: none. All parameters are independent and optional. + + Other guidance: + - All parameters are optional — omit to get all available sources. + - Returns source metadata: id, url, priority, languages, countries, categories. + - Use the returned `source_id` values as input to `domain` in other tools. + - No pagination — returns all matching sources in one call. + + Examples: + - `country="in", language="hi"` → Hindi sources in India + - `category="technology", priority_domain="top"` → top tech sources + - `domainurl="reuters.com,bbc.com"` → check if specific domains are available + """ + error = check_mutex_groups(locals()) + if error: + return f"Error: {error}" + + data = await fetch( + "sources", + { + "country": country, + "category": category, + "language": language, + "prioritydomain": priority_domain, + "domainurl": domainurl, + }, + ) + return format_sources(data) diff --git a/src/newsdata_mcp/validators.py b/src/newsdata_mcp/validators.py new file mode 100644 index 0000000..69b3c84 --- /dev/null +++ b/src/newsdata_mcp/validators.py @@ -0,0 +1,75 @@ +"""Client-side parameter validation for NewsData MCP tools. + +The NewsData REST API enforces several "use only one of" constraints +server-side and returns HTTP 422 when they're violated. We mirror those +checks at the tool boundary so the LLM gets a fast, specific error +message instead of a generic "Invalid parameters" after a wasted +round-trip — and so the message references the *Python kwarg names* +the LLM actually passed, not NewsData's wire names. + +`MUTEX_GROUPS` is the single source of truth for these constraints; see +the official `newsdataapi` SDK's `_MUTEX_GROUPS` for the upstream +equivalent. +""" +from collections.abc import Mapping +from typing import Any + +# Mutually-exclusive parameter groups, using Python kwarg names (what +# the tool function receives), not NewsData's wire names. Setting more +# than one entry from any group in the same call returns an error from +# `check_mutex_groups` before any HTTP call goes out. +MUTEX_GROUPS: tuple[tuple[str, ...], ...] = ( + ("q", "q_in_title", "q_in_meta"), + ("country", "exclude_country"), + ("category", "exclude_category"), + ("language", "exclude_language"), + ("domain", "domainurl", "exclude_domain"), +) + + +def check_mutex_groups(kwargs: Mapping[str, Any]) -> str | None: + """Return an LLM-readable error message if any mutex group has more + than one non-None member set, else None. + + `kwargs` should be the Python kwargs the tool received (typically + ``locals()`` called at the top of the tool function, before any + local variables are introduced). Missing keys are treated as not + set; unknown keys are ignored. + + The returned string is designed to be useful to an LLM reading the + tool's error output: it names the conflicting parameters in + single-quoted form, lists the full mutex group so the LLM knows + what its options are, and ends with the explicit fix ("Omit the + others."). + """ + for group in MUTEX_GROUPS: + set_in_group = [name for name in group if kwargs.get(name) is not None] + if len(set_in_group) > 1: + conflict = ", ".join(repr(name) for name in set_in_group) + allowed = ", ".join(repr(name) for name in group) + return ( + f"Cannot combine mutually exclusive parameters: {conflict}. " + f"Pass only one of: {allowed}. Omit the others." + ) + return None + + +def check_sentiment_score_requires_sentiment( + kwargs: Mapping[str, Any], +) -> str | None: + """Return an error if ``sentiment_score`` is set but ``sentiment`` is not. + + NewsData's ``sentiment_score`` is a confidence threshold that only + makes sense when paired with a ``sentiment`` label (positive / + negative / neutral) — the API rejects ``sentiment_score`` alone + with a 422. We mirror the check client-side so the LLM gets the + specific guidance (set ``sentiment`` too) instead of a generic + "Invalid parameters" after a wasted round-trip. + """ + if kwargs.get("sentiment_score") is not None and kwargs.get("sentiment") is None: + return ( + "Parameter 'sentiment_score' requires 'sentiment' to also be set. " + "Pass `sentiment='positive'`, `'negative'`, or `'neutral'` alongside " + "`sentiment_score`, or omit `sentiment_score`." + ) + return None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9e4927e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +"""Shared test fixtures. + +Tests must not require a real API key; we set a placeholder before any +of the package modules are imported so the missing-key warning doesn't +fire and so `fetch()` doesn't short-circuit. Real network calls are +mocked via `respx`. +""" +import os + +os.environ.setdefault("NEWSDATA_API_KEY", "test_key_not_real") +# Tests assert on retry behavior but must run fast: 3 attempts at sub-ms +# sleep instead of the production 5 attempts at 2s-60s. +os.environ.setdefault("NEWSDATA_MAX_RETRIES", "3") +os.environ.setdefault("NEWSDATA_RETRY_BACKOFF", "0.001") +os.environ.setdefault("NEWSDATA_RETRY_BACKOFF_MAX", "0.01") + +import pytest # noqa: E402 + +from newsdata_mcp import http # noqa: E402 + + +@pytest.fixture(autouse=True) +async def reset_http_client(monkeypatch): + """Force a fresh singleton `httpx.AsyncClient` per test so the + in-process client picks up the patched API key and mocked + transport.""" + # Make sure http.NEWSDATA_API_KEY reflects whatever the test set + # (the conftest default is enough for most tests). + monkeypatch.setattr(http, "NEWSDATA_API_KEY", os.environ["NEWSDATA_API_KEY"]) + + # Reset the singleton before the test runs. + if http._client is not None: + await http._client.aclose() + http._client = None + + yield + + # Tear down after the test so the next one gets a clean client. + if http._client is not None: + await http._client.aclose() + http._client = None diff --git a/tests/test_formatters.py b/tests/test_formatters.py new file mode 100644 index 0000000..b75a028 --- /dev/null +++ b/tests/test_formatters.py @@ -0,0 +1,367 @@ +"""Unit tests for the pure-text rendering layer. + +`formatters` has no network or framework dependencies; these tests are +synthetic-data only. +""" +from newsdata_mcp.formatters import ( + _append_field, + _clean_text, + _format_article_item, + _format_error, + _format_sentiment_stats, + format_articles, + format_counts, + format_sources, +) + +# ---------- _clean_text ---------- + +def test_clean_text_strips_whitespace(): + assert _clean_text(" hello ") == "hello" + + +def test_clean_text_whitespace_only_is_none(): + assert _clean_text(" ") is None + + +def test_clean_text_none_input_is_none(): + assert _clean_text(None) is None + + +def test_clean_text_non_string_is_none(): + assert _clean_text(123) is None + assert _clean_text([1, 2]) is None + + +# ---------- _format_sentiment_stats ---------- + +def test_sentiment_stats_renders_dict(): + out = _format_sentiment_stats({"positive": 70, "neutral": 20, "negative": 10}) + assert "positive=70" in out + assert "neutral=20" in out + assert "negative=10" in out + + +def test_sentiment_stats_empty_dict_is_none(): + assert _format_sentiment_stats({}) is None + + +def test_sentiment_stats_none_is_none(): + assert _format_sentiment_stats(None) is None + + +def test_sentiment_stats_non_dict_is_none(): + assert _format_sentiment_stats("not a dict") is None + + +# ---------- _append_field ---------- + +def test_append_field_skips_none(): + lines = [] + _append_field(lines, "test", None) + assert lines == [] + + +def test_append_field_renders_bool_lowercase(): + lines = [] + _append_field(lines, "duplicate", True) + _append_field(lines, "duplicate", False) + assert lines == ["duplicate: true", "duplicate: false"] + + +def test_append_field_renders_list_joined(): + lines = [] + _append_field(lines, "categories", ["a", "b", "c"]) + assert lines == ["categories: a, b, c"] + + +def test_append_field_drops_falsy_list_items(): + lines = [] + _append_field(lines, "x", ["a", None, "", "b"]) + assert lines == ["x: a, b"] + + +def test_append_field_str_passthrough(): + lines = [] + _append_field(lines, "title", "hello") + assert lines == ["title: hello"] + + +# ---------- _format_article_item ---------- + +def test_format_article_item_omits_missing_fields(): + lines = _format_article_item({"article_id": "x", "title": "hi"}) + assert any(line.startswith("article_id:") for line in lines) + assert any(line.startswith("title:") for line in lines) + # No description in the input, so no description line should be emitted. + assert not any(line.startswith("description:") for line in lines) + + +# ---------- format_articles ---------- + +def test_format_articles_error_envelope(): + out = format_articles({"status": "error", "message": "bad"}, "latest") + assert out == "Error: bad" + + +def test_format_articles_error_with_status_code(): + out = format_articles( + {"status": "error", "message": "Unauthorized.", "status_code": 401}, + "latest", + ) + assert out == "Error (HTTP 401): Unauthorized." + + +def test_format_articles_error_with_status_code_and_retry_after(): + out = format_articles( + { + "status": "error", + "message": "Rate limit exceeded.", + "status_code": 429, + "retry_after": 30, + }, + "latest", + ) + assert out == "Error (HTTP 429, retry after 30s): Rate limit exceeded." + + +def test_format_articles_empty(): + out = format_articles( + {"status": "success", "data": {"results": []}}, "latest" + ) + assert out == "No latest articles found matching your query." + + +def test_format_articles_populated_headers_and_body(): + out = format_articles( + { + "status": "success", + "data": { + "totalResults": 2, + "nextPage": "next_token_abc", + "results": [ + { + "article_id": "abc", + "title": "Hello world", + "link": "https://example.com/", + "pubDate": "2026-05-08", + }, + {"article_id": "def", "title": "Second"}, + ], + }, + }, + "latest", + ) + assert "endpoint: latest" in out + assert "total_results: 2" in out + assert "returned_results: 2" in out + assert "next_page: next_token_abc" in out + assert "title: Hello world" in out + assert "title: Second" in out + assert "url: https://example.com/" in out + + +# ---------- format_sources ---------- + +def test_format_sources_error_envelope(): + out = format_sources({"status": "error", "message": "auth fail"}) + assert out == "Error: auth fail" + + +def test_format_sources_error_with_status_code(): + out = format_sources( + {"status": "error", "message": "Unauthorized.", "status_code": 401} + ) + assert out == "Error (HTTP 401): Unauthorized." + + +def test_format_sources_empty(): + out = format_sources({"status": "success", "data": {"results": []}}) + assert out == "No sources found matching your filters." + + +def test_format_sources_populated(): + """Fixture mirrors a real `/sources` response (envelope-level + `totalResults`, source objects with id/name/url/icon/priority/ + description/category/language/country/total_article/last_fetch).""" + out = format_sources( + { + "status": "success", + "data": { + "totalResults": 100, + "results": [ + { + "id": "computerhoy_20minutos_es", + "name": "Computer Hoy", + "url": "https://computerhoy.20minutos.es", + "icon": "https://n.bytvi.com/computerhoy_20minutos_es.png", + "priority": 124675, + "description": "Web especializada en noticias y análisis.", + "category": ["top"], + "language": ["spanish"], + "country": ["spain"], + "total_article": 1293, + "last_fetch": "2026-05-10 18:07:54", + } + ], + }, + } + ) + assert "endpoint: sources" in out + assert "total_results: 100" in out + assert "returned_results: 1" in out + assert "[1] Computer Hoy" in out + assert "source_id: computerhoy_20minutos_es" in out + assert "url: https://computerhoy.20minutos.es" in out + assert "icon: https://n.bytvi.com/computerhoy_20minutos_es.png" in out + assert "priority: 124675" in out + assert "languages: spanish" in out + assert "countries: spain" in out + assert "categories: top" in out + assert "total_article: 1293" in out + assert "last_fetch: 2026-05-10 18:07:54" in out + assert "description: Web especializada en noticias y análisis." in out + + +# Regression for the original bug: `_format_sources` was reading +# `data.get("results")` directly on the fetch wrapper, so every +# successful call rendered "No sources found". +def test_format_sources_unwraps_fetch_envelope(): + envelope = { + "status": "success", + "data": {"results": [{"id": "x", "name": "X"}]}, + } + out = format_sources(envelope) + assert "[1] X" in out + assert out != "No sources found matching your filters." + + +# ---------- format_counts (new in Step 4) ---------- + + +def test_format_counts_error_envelope(): + out = format_counts({"status": "error", "message": "bad"}, "count") + assert out == "Error: bad" + + +def test_format_counts_error_with_status_code(): + out = format_counts( + {"status": "error", "message": "Server down.", "status_code": 503}, + "count", + ) + assert out == "Error (HTTP 503): Server down." + + +# ---------- _format_error (the helper) ---------- + + +def test_format_error_no_extras(): + """No status_code, no retry_after — bare 'Error: ...' format.""" + assert _format_error({"message": "boom"}) == "Error: boom" + + +def test_format_error_status_code_only(): + assert ( + _format_error({"message": "boom", "status_code": 404}) + == "Error (HTTP 404): boom" + ) + + +def test_format_error_retry_after_only(): + """retry_after without status_code shouldn't really happen, but the + helper still renders it cleanly.""" + assert ( + _format_error({"message": "boom", "retry_after": 12}) + == "Error (retry after 12s): boom" + ) + + +def test_format_error_both_extras_in_stable_order(): + """HTTP code first, retry_after second, comma-separated. Stable + order matters for any LLM that learns to parse the suffix.""" + assert ( + _format_error({"message": "boom", "status_code": 429, "retry_after": 12}) + == "Error (HTTP 429, retry after 12s): boom" + ) + + +def test_format_error_missing_message_falls_back(): + assert _format_error({}) == "Error: Unknown error" + + +def test_format_error_status_code_none_is_omitted(): + """status_code=None means 'not applicable' — don't render '(HTTP None)'.""" + assert ( + _format_error({"message": "network down", "status_code": None}) + == "Error: network down" + ) + + +def test_format_counts_empty_results(): + out = format_counts({"status": "success", "data": {"results": []}}, "count") + assert out == "No results found for count over the given range." + + +def test_format_counts_list_of_buckets(): + """Bucket-list responses sum each bucket's `count` for the rendered + `total_results` line. Real wire shape: `[{"dateTime": "...", "count": N}, ...]`.""" + out = format_counts( + { + "status": "success", + "data": { + "nextPage": None, + "results": [ + {"dateTime": "2021-01-01 00:00:00", "count": 100}, + {"dateTime": "2020-12-31 00:00:00", "count": 150}, + ], + }, + }, + "count", + ) + assert "endpoint: count" in out + assert "total_results: 250" in out # 100 + 150 + assert "buckets: 2" in out + assert "Bucket 1:" in out + assert "Bucket 2:" in out + assert "dateTime: 2021-01-01 00:00:00" in out + assert "count: 100" in out + assert "count: 150" in out + + +def test_format_counts_aggregate_dict(): + """When `interval="all"` or no interval is specified, NewsData returns + `results` as a single aggregate dict: `{"count": N}`.""" + out = format_counts( + { + "status": "success", + "data": { + "results": {"count": 190440}, + }, + }, + "count", + ) + assert "endpoint: count" in out + assert "total_results: 190440" in out + assert "aggregate:" in out + assert "count: 190440" in out + + +def test_format_counts_renders_next_page_token(): + out = format_counts( + { + "status": "success", + "data": { + "nextPage": "1605225600000000000", + "results": [{"dateTime": "2020-11-13 00:00:00", "count": 665}], + }, + }, + "count", + ) + assert "next_page: 1605225600000000000" in out + + +def test_format_counts_endpoint_name_used_in_no_results_message(): + out = format_counts( + {"status": "success", "data": {"results": []}}, "crypto/count" + ) + assert "crypto/count" in out diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..fe9ad14 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,596 @@ +"""Tests for the HTTP layer. + +Mocks the network with `respx` so no real NewsData calls are made. +""" +from datetime import UTC + +import httpx +import respx + +from newsdata_mcp import http + + +@respx.mock +async def test_fetch_success_wraps_payload(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response( + 200, + json={"status": "success", "totalResults": 1, "results": [{"article_id": "x"}]}, + ) + ) + result = await http.fetch("latest", {"q": "test"}) + assert result["status"] == "success" + assert result["data"]["results"][0]["article_id"] == "x" + + +@respx.mock +async def test_fetch_drops_none_params(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("latest", {"q": "btc", "country": None, "size": 5}) + request = route.calls[0].request + qs = dict(request.url.params) + assert qs == {"q": "btc", "size": "5"} + + +@respx.mock +async def test_fetch_401_friendly_message(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(401, text="Unauthorized") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["message"] == "Unauthorized. API key is invalid." + + +@respx.mock +async def test_fetch_422_friendly_message(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(422, text="Bad") + ) + result = await http.fetch("latest", {}) + assert "Invalid parameters" in result["message"] + + +@respx.mock +async def test_fetch_429_friendly_message(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(429, text="Too many") + ) + result = await http.fetch("latest", {}) + assert "Rate limit" in result["message"] + + +@respx.mock +async def test_fetch_500_truncates_body(): + long_body = "X" * 10_000 + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(500, text=long_body) + ) + result = await http.fetch("latest", {}) + assert "HTTP 500" in result["message"] + assert "…" in result["message"] + # Prefix "HTTP 500 from Newsdata.io: " is 27 chars, body 500, ellipsis 1 + assert len(result["message"]) == 528 + + +@respx.mock +async def test_fetch_short_5xx_body_not_truncated(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(502, text="Bad Gateway") + ) + result = await http.fetch("latest", {}) + assert result["message"] == "HTTP 502 from Newsdata.io: Bad Gateway" + + +@respx.mock +async def test_fetch_soft_error_results_message(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response( + 200, + json={"status": "error", "results": {"code": "X", "message": "Soft fail"}}, + ) + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["message"] == "Soft fail" + + +@respx.mock +async def test_fetch_soft_error_top_level_message(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response( + 200, + json={"status": "error", "message": "Top-level message"}, + ) + ) + result = await http.fetch("latest", {}) + assert result["message"] == "Top-level message" + + +@respx.mock +async def test_fetch_soft_error_unknown_falls_back(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "error"}) + ) + result = await http.fetch("latest", {}) + assert result["message"] == "Unknown API error." + + +@respx.mock +async def test_fetch_non_json_response(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, text="not json") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert "non-JSON" in result["message"] + + +@respx.mock +async def test_fetch_timeout(): + respx.get("https://newsdata.io/api/1/latest").mock( + side_effect=httpx.TimeoutException("timed out") + ) + result = await http.fetch("latest", {}) + assert "timed out" in result["message"].lower() + + +@respx.mock +async def test_fetch_connect_error(): + respx.get("https://newsdata.io/api/1/latest").mock( + side_effect=httpx.ConnectError("nope") + ) + result = await http.fetch("latest", {}) + assert "Failed to connect" in result["message"] + + +async def test_fetch_missing_api_key_short_circuits(monkeypatch): + monkeypatch.setattr(http, "NEWSDATA_API_KEY", None) + result = await http.fetch("latest", {"q": "anything"}) + assert result["status"] == "error" + assert "not configured" in result["message"] + + +@respx.mock +async def test_fetch_uses_header_auth_not_query_string(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("latest", {"q": "x"}) + request = route.calls[0].request + # Auth comes via header, NOT as `apikey=...` in the URL. + assert request.headers["X-ACCESS-KEY"] == "test_key_not_real" + assert "apikey" not in dict(request.url.params) + + +@respx.mock +async def test_fetch_sets_user_agent_header(): + route = respx.get("https://newsdata.io/api/1/sources").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("sources", {}) + request = route.calls[0].request + assert request.headers["User-Agent"].startswith("newsdata-mcp/") + + +@respx.mock +async def test_fetch_reuses_singleton_client(): + respx.get("https://newsdata.io/api/1/sources").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("sources", {}) + first_id = id(http._client) + await http.fetch("sources", {}) + second_id = id(http._client) + assert first_id == second_id + + +# --------------------------------------------------------------------------- +# Step 2 — parameter coercion in _normalize_params. +# These tests confirm the LLM-friendly input forms (bool, list, int) are +# accepted and translated to the wire format NewsData expects. +# --------------------------------------------------------------------------- + + +def test_normalize_drops_none(): + assert http._normalize_params({"q": "x", "country": None}) == {"q": "x"} + + +def test_normalize_bool_flag_true_to_one(): + assert http._normalize_params({"image": True}) == {"image": 1} + + +def test_normalize_bool_flag_false_to_zero(): + assert http._normalize_params({"image": False, "video": False}) == { + "image": 0, + "video": 0, + } + + +def test_normalize_removeduplicate_true_to_one(): + assert http._normalize_params({"removeduplicate": True}) == {"removeduplicate": 1} + + +def test_normalize_removeduplicate_false_is_omitted(): + """The API rejects `0`; we drop the key entirely so it's never sent.""" + assert http._normalize_params({"removeduplicate": False}) == {} + + +def test_normalize_removeduplicate_false_alongside_other_params(): + out = http._normalize_params({"q": "btc", "removeduplicate": False, "size": 5}) + assert out == {"q": "btc", "size": 5} + assert "removeduplicate" not in out + + +def test_normalize_list_joins_with_comma_no_spaces(): + assert http._normalize_params({"country": ["us", "gb", "in"]}) == { + "country": "us,gb,in" + } + + +def test_normalize_list_with_single_item(): + assert http._normalize_params({"category": ["technology"]}) == { + "category": "technology" + } + + +def test_normalize_empty_list_is_omitted(): + """An empty list means 'no filter' — drop the key.""" + assert http._normalize_params({"country": []}) == {} + + +def test_normalize_list_drops_falsy_items(): + """Defensive: empty strings and Nones inside a list are dropped.""" + assert http._normalize_params({"tag": ["food", "", None, "tourism"]}) == { + "tag": "food,tourism" + } + + +def test_normalize_string_csv_passthrough(): + """Legacy form: comma-separated string still works unchanged.""" + assert http._normalize_params({"country": "us,gb"}) == {"country": "us,gb"} + + +def test_normalize_int_passthrough(): + """ints (size, timeframe-as-int) pass through unchanged.""" + assert http._normalize_params({"size": 10, "timeframe": 24}) == { + "size": 10, + "timeframe": 24, + } + + +def test_normalize_mixed_inputs_realistic_call(): + """End-to-end shape an LLM would actually produce.""" + out = http._normalize_params({ + "q": "bitcoin", + "country": ["us", "gb"], + "language": ["en"], + "image": True, + "video": False, + "removeduplicate": True, + "size": 10, + "timeframe": 6, + "exclude_country": None, + }) + assert out == { + "q": "bitcoin", + "country": "us,gb", + "language": "en", + "image": 1, + "video": 0, + "removeduplicate": 1, + "size": 10, + "timeframe": 6, + } + + +@respx.mock +async def test_fetch_sends_bool_true_as_one_on_wire(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("latest", {"image": True}) + qs = dict(route.calls[0].request.url.params) + assert qs["image"] == "1" + + +@respx.mock +async def test_fetch_sends_bool_false_as_zero_on_wire(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("latest", {"image": False}) + qs = dict(route.calls[0].request.url.params) + assert qs["image"] == "0" + + +@respx.mock +async def test_fetch_removeduplicate_false_not_on_wire(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("latest", {"removeduplicate": False}) + qs = dict(route.calls[0].request.url.params) + assert "removeduplicate" not in qs + + +@respx.mock +async def test_fetch_list_joined_with_comma_on_wire(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await http.fetch("latest", {"country": ["us", "gb", "in"]}) + qs = dict(route.calls[0].request.url.params) + assert qs["country"] == "us,gb,in" + + +# --------------------------------------------------------------------------- +# Step 5 — _parse_retry_after, _compute_backoff, retry loop, error envelope. +# --------------------------------------------------------------------------- + + +# _parse_retry_after — integer-seconds form + +def test_parse_retry_after_integer(): + assert http._parse_retry_after("30") == 30 + + +def test_parse_retry_after_zero(): + assert http._parse_retry_after("0") == 0 + + +def test_parse_retry_after_negative_clamped_to_zero(): + assert http._parse_retry_after("-5") == 0 + + +def test_parse_retry_after_with_whitespace(): + assert http._parse_retry_after(" 42 ") == 42 + + +# _parse_retry_after — HTTP-date form (RFC 7231) + +def test_parse_retry_after_http_date_in_future(): + """An HTTP-date 30 seconds in the future parses to ~30s.""" + from datetime import datetime, timedelta + from email.utils import format_datetime + future = datetime.now(tz=UTC) + timedelta(seconds=30) + seconds = http._parse_retry_after(format_datetime(future, usegmt=True)) + # Some jitter expected; should be in [28, 31] range. + assert seconds is not None + assert 28 <= seconds <= 31 + + +def test_parse_retry_after_http_date_in_past_clamped_to_zero(): + from datetime import datetime, timedelta + from email.utils import format_datetime + past = datetime.now(tz=UTC) - timedelta(seconds=30) + assert http._parse_retry_after(format_datetime(past, usegmt=True)) == 0 + + +# _parse_retry_after — invalid inputs + +def test_parse_retry_after_none_returns_none(): + assert http._parse_retry_after(None) is None + + +def test_parse_retry_after_empty_string_returns_none(): + assert http._parse_retry_after("") is None + assert http._parse_retry_after(" ") is None + + +def test_parse_retry_after_garbage_returns_none(): + assert http._parse_retry_after("not a thing") is None + + +# _compute_backoff + +def test_compute_backoff_doubles_each_attempt(monkeypatch): + """Base 2.0s: 2 → 4 → 8 → 16 ... up to the cap.""" + monkeypatch.setattr(http, "RETRY_BACKOFF", 2.0) + monkeypatch.setattr(http, "RETRY_BACKOFF_MAX", 60.0) + assert http._compute_backoff(1) == 2.0 + assert http._compute_backoff(2) == 4.0 + assert http._compute_backoff(3) == 8.0 + assert http._compute_backoff(4) == 16.0 + assert http._compute_backoff(5) == 32.0 + + +def test_compute_backoff_capped(monkeypatch): + """Cap kicks in once the doubled value exceeds RETRY_BACKOFF_MAX.""" + monkeypatch.setattr(http, "RETRY_BACKOFF", 2.0) + monkeypatch.setattr(http, "RETRY_BACKOFF_MAX", 60.0) + assert http._compute_backoff(6) == 60.0 # 2 * 32 = 64, capped to 60. + assert http._compute_backoff(10) == 60.0 # any later attempt = cap. + + +# Retry loop — happy paths + +@respx.mock +async def test_retry_recovers_after_500_then_200(): + """First call returns 500; second returns 200. fetch() returns success.""" + route = respx.get("https://newsdata.io/api/1/latest").mock( + side_effect=[ + httpx.Response(503, text="upstream is down"), + httpx.Response(200, json={"status": "success", "results": []}), + ] + ) + result = await http.fetch("latest", {}) + assert result["status"] == "success" + assert route.call_count == 2 + + +@respx.mock +async def test_retry_recovers_after_timeout_then_200(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + side_effect=[ + httpx.TimeoutException("first call hung"), + httpx.Response(200, json={"status": "success", "results": []}), + ] + ) + result = await http.fetch("latest", {}) + assert result["status"] == "success" + assert route.call_count == 2 + + +@respx.mock +async def test_retry_recovers_after_connect_error_then_200(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + side_effect=[ + httpx.ConnectError("dns failure"), + httpx.Response(200, json={"status": "success", "results": []}), + ] + ) + result = await http.fetch("latest", {}) + assert result["status"] == "success" + assert route.call_count == 2 + + +# Retry loop — exhaustion + +@respx.mock +async def test_retry_exhausted_on_5xx_returns_last_error(monkeypatch): + """All MAX_RETRIES attempts get 5xx; final result includes status_code.""" + monkeypatch.setattr(http, "MAX_RETRIES", 3) + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(503, text="still down") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["status_code"] == 503 + assert "HTTP 503" in result["message"] + + +# Retry loop — never retry on permanent failures + +@respx.mock +async def test_401_no_retry(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(401, text="bad key") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["status_code"] == 401 + # Permanent failure: exactly one call, not MAX_RETRIES. + assert route.call_count == 1 + + +@respx.mock +async def test_422_no_retry(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(422, text="bad params") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["status_code"] == 422 + assert route.call_count == 1 + + +@respx.mock +async def test_other_4xx_no_retry(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(404, text="not found") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["status_code"] == 404 + assert route.call_count == 1 + + +@respx.mock +async def test_non_json_2xx_no_retry(): + """Non-JSON 200 is treated as permanent (some maintenance pages).""" + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, text="maintenance") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert "non-JSON" in result["message"] + assert route.call_count == 1 + + +@respx.mock +async def test_soft_error_200_no_retry(): + """200 OK with status=error is a user-side issue (e.g. bad date). + No retry; status_code captured as 200.""" + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response( + 200, + json={"status": "error", "results": {"message": "Bad date"}}, + ) + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["status_code"] == 200 + assert result["message"] == "Bad date" + assert route.call_count == 1 + + +# Retry loop — 429 honoring Retry-After + +@respx.mock +async def test_429_with_retry_after_integer_honored(): + """429 first, then 200. Retry-After=0 lets the test fly past the sleep.""" + route = respx.get("https://newsdata.io/api/1/latest").mock( + side_effect=[ + httpx.Response(429, headers={"Retry-After": "0"}, text="too many"), + httpx.Response(200, json={"status": "success", "results": []}), + ] + ) + result = await http.fetch("latest", {}) + assert result["status"] == "success" + assert route.call_count == 2 + + +@respx.mock +async def test_429_exhausted_includes_retry_after_in_envelope(monkeypatch): + """Retry-After from the server is preserved in the final envelope. + We patch asyncio.sleep to no-op so the test doesn't actually wait the + 30s the header asks for — production-side behaviour (honoring the + server's request) is covered by `test_429_with_retry_after_integer_honored`.""" + import asyncio as _asyncio + + async def _no_sleep(seconds): + return None + + monkeypatch.setattr(_asyncio, "sleep", _no_sleep) + monkeypatch.setattr(http, "MAX_RETRIES", 2) + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(429, headers={"Retry-After": "30"}, text="too many") + ) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["status_code"] == 429 + assert result["retry_after"] == 30 + + +# Error envelope shape — every error path includes status_code + +@respx.mock +async def test_error_envelope_carries_status_code(monkeypatch): + """Every error path returns a `status_code` field (None for non-HTTP). + The LLM (and formatters) rely on this to render HTTP context.""" + monkeypatch.setattr(http, "MAX_RETRIES", 1) + cases = [ + (httpx.Response(401, text="x"), 401), + (httpx.Response(422, text="x"), 422), + (httpx.Response(429, text="x"), 429), + (httpx.Response(403, text="x"), 403), + (httpx.Response(503, text="x"), 503), + ] + for response, expected_code in cases: + respx.get("https://newsdata.io/api/1/latest").mock(return_value=response) + result = await http.fetch("latest", {}) + assert result["status_code"] == expected_code, ( + f"expected status_code {expected_code}, got {result.get('status_code')}" + ) + respx.reset() + + +async def test_missing_api_key_envelope_shape(monkeypatch): + monkeypatch.setattr(http, "NEWSDATA_API_KEY", None) + result = await http.fetch("latest", {}) + assert result["status"] == "error" + assert result["status_code"] is None + assert "not configured" in result["message"] diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..f04aba1 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,107 @@ +"""Integration tests — hit the live NewsData.io API. + +These tests are SKIPPED by default. Run them explicitly with:: + + NEWSDATA_INTEGRATION_KEY= uv run pytest -m integration + +Or run unit + integration together with ``-m ""``. + +Each test: +- Reads ``NEWSDATA_INTEGRATION_KEY`` from the environment (skips if unset). +- Patches the singleton http client and the module-level API key so the + call goes out with the real credential (the unit conftest sets a fake + key, which we override here). +- Asserts a minimal contract: status==success, results is a list, basic + shape. We deliberately do not assert on counts or content — those vary. +""" +import os + +import pytest + +from newsdata_mcp import http +from newsdata_mcp.tools.archive import get_archive_news +from newsdata_mcp.tools.count import get_news_counts +from newsdata_mcp.tools.crypto import get_crypto_news +from newsdata_mcp.tools.crypto_count import get_crypto_counts +from newsdata_mcp.tools.latest import get_latest_news +from newsdata_mcp.tools.market import get_market_news +from newsdata_mcp.tools.market_count import get_market_counts +from newsdata_mcp.tools.sources import get_news_sources + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def real_api_key(): + """Inject the real API key for the duration of each test, then + restore the placeholder so unit tests in the same session stay + isolated. + """ + key = os.environ.get("NEWSDATA_INTEGRATION_KEY") + if not key: + pytest.skip("NEWSDATA_INTEGRATION_KEY env var not set") + + saved = http.NEWSDATA_API_KEY + saved_client = http._client + http.NEWSDATA_API_KEY = key + http._client = None + try: + yield + finally: + if http._client is not None: + await http._client.aclose() + http._client = saved_client + http.NEWSDATA_API_KEY = saved + + +def _assert_renders_endpoint(out: str, endpoint_name: str) -> None: + """A successful tool call always starts with ``endpoint: `` or + with a 'no results' / 'no counts' line; both are acceptable for an + integration test (we're verifying connectivity, not content).""" + if not (out.startswith(f"endpoint: {endpoint_name}") or "No " in out.splitlines()[0]): + pytest.fail(f"Unexpected output for {endpoint_name}:\n{out[:300]}") + + +async def test_live_get_news_sources(): + out = await get_news_sources(country="us", language="en") + _assert_renders_endpoint(out, "sources") + + +async def test_live_get_latest_news(): + out = await get_latest_news(q="bitcoin", size=1) + _assert_renders_endpoint(out, "latest") + + +async def test_live_get_archive_news(): + out = await get_archive_news( + from_date="2024-01-01", + to_date="2024-01-07", + q="elections", + size=1, + ) + _assert_renders_endpoint(out, "archive") + + +async def test_live_get_crypto_news(): + out = await get_crypto_news(coin="btc", size=1) + _assert_renders_endpoint(out, "crypto") + + +async def test_live_get_market_news(): + out = await get_market_news(symbol="AAPL", size=1) + _assert_renders_endpoint(out, "market") + + +async def test_live_get_news_counts(): + out = await get_news_counts(from_date="2024-01-01", to_date="2024-01-07", q="bitcoin") + _assert_renders_endpoint(out, "count") + + +async def test_live_get_crypto_counts(): + out = await get_crypto_counts(from_date="2024-01-01", to_date="2024-01-07", coin="btc") + _assert_renders_endpoint(out, "crypto/count") + + +async def test_live_get_market_counts(): + out = await get_market_counts(from_date="2024-01-01", to_date="2024-01-07", symbol="AAPL") + _assert_renders_endpoint(out, "market/count") diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..5a10bdb --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,687 @@ +"""End-to-end tests that exercise the @mcp.tool() functions with a +mocked NewsData API. These validate the snake-case → camelCase param +mapping and that the formatter is correctly wired per endpoint. +""" +import httpx +import respx + +from newsdata_mcp.tools.archive import get_archive_news +from newsdata_mcp.tools.count import get_news_counts +from newsdata_mcp.tools.crypto import get_crypto_news +from newsdata_mcp.tools.crypto_count import get_crypto_counts +from newsdata_mcp.tools.latest import get_latest_news +from newsdata_mcp.tools.market import get_market_news +from newsdata_mcp.tools.market_count import get_market_counts +from newsdata_mcp.tools.sources import get_news_sources + + +@respx.mock +async def test_latest_news_renders_text(): + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response( + 200, + json={ + "status": "success", + "totalResults": 1, + "results": [ + {"article_id": "a1", "title": "Latest title", "link": "https://x"} + ], + }, + ) + ) + out = await get_latest_news(q="bitcoin", country="us", size=10) + assert "endpoint: latest" in out + assert "title: Latest title" in out + assert "url: https://x" in out + + +@respx.mock +async def test_latest_news_maps_snake_case_to_camel_case(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news( + q_in_title="apple", + exclude_country="cn", + priority_domain="top", + article_id="aabbccddeeff00112233445566778899", + ) + qs = dict(route.calls[0].request.url.params) + # snake_case in Python → NewsData's actual param names. + assert qs["qInTitle"] == "apple" + assert qs["excludecountry"] == "cn" + assert qs["prioritydomain"] == "top" + assert qs["id"] == "aabbccddeeff00112233445566778899" + # And no leak of the Python kwarg names. + assert "q_in_title" not in qs + assert "exclude_country" not in qs + + +@respx.mock +async def test_archive_news_uses_archive_endpoint(): + route = respx.get("https://newsdata.io/api/1/archive").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + out = await get_archive_news(q="war", from_date="2024-01-01", to_date="2024-01-31") + assert route.called + qs = dict(route.calls[0].request.url.params) + assert qs["from_date"] == "2024-01-01" + assert qs["to_date"] == "2024-01-31" + # Empty result on the archive endpoint says "archive" not "latest". + assert "archive" in out + + +@respx.mock +async def test_crypto_news_uses_crypto_endpoint_and_passes_coin(): + route = respx.get("https://newsdata.io/api/1/crypto").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_crypto_news(coin="btc,eth", sentiment="positive") + qs = dict(route.calls[0].request.url.params) + assert qs["coin"] == "btc,eth" + assert qs["sentiment"] == "positive" + + +@respx.mock +async def test_market_news_uses_market_endpoint_and_passes_symbol(): + route = respx.get("https://newsdata.io/api/1/market").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_market_news(symbol="AAPL,NVDA", country="us") + qs = dict(route.calls[0].request.url.params) + assert qs["symbol"] == "AAPL,NVDA" + assert qs["country"] == "us" + + +@respx.mock +async def test_news_sources_renders_source_metadata(): + """Regression: with the original bug this returned 'No sources found' + even on a successful response. After the fix the body is rendered.""" + respx.get("https://newsdata.io/api/1/sources").mock( + return_value=httpx.Response( + 200, + json={ + "status": "success", + "results": [ + { + "id": "bbc", + "name": "BBC", + "url": "https://bbc.com", + "language": ["english"], + } + ], + }, + ) + ) + out = await get_news_sources(country="gb") + assert "[1] BBC" in out + assert "source_id: bbc" in out + assert out != "No sources found matching your filters." + + +@respx.mock +async def test_news_sources_surfaces_api_error(): + respx.get("https://newsdata.io/api/1/sources").mock( + return_value=httpx.Response(401, text="bad") + ) + out = await get_news_sources() + # Step 5: error envelope now carries status_code; formatter renders + # `Error (HTTP 401): ...` instead of plain `Error: ...`. + assert out.startswith("Error (HTTP 401):") + assert "Unauthorized" in out + + +# --------------------------------------------------------------------------- +# Mutex validation runs at the tool boundary, *before* fetch() is called. +# These tests use respx.mock without registering any routes — if a tool +# attempts a network call despite a mutex violation, respx fails the test. +# --------------------------------------------------------------------------- + + +@respx.mock(assert_all_called=False) +async def test_latest_mutex_q_and_q_in_title_short_circuits(): + out = await get_latest_news(q="bitcoin", q_in_title="ethereum") + assert out.startswith("Error:") + assert "'q'" in out + assert "'q_in_title'" in out + assert "mutually exclusive" in out + # No network call was made. + assert len(respx.calls) == 0 + + +@respx.mock(assert_all_called=False) +async def test_latest_mutex_country_and_exclude_country_short_circuits(): + out = await get_latest_news(country="us", exclude_country="gb") + assert out.startswith("Error:") + assert "'country'" in out + assert "'exclude_country'" in out + assert len(respx.calls) == 0 + + +@respx.mock(assert_all_called=False) +async def test_archive_mutex_three_way_domain_short_circuits(): + out = await get_archive_news( + domain="bbc", + domainurl="bbc.com", + exclude_domain="reuters", + ) + assert out.startswith("Error:") + assert "'domain'" in out + assert "'domainurl'" in out + assert "'exclude_domain'" in out + assert len(respx.calls) == 0 + + +@respx.mock(assert_all_called=False) +async def test_crypto_mutex_language_and_exclude_language_short_circuits(): + out = await get_crypto_news(language="en", exclude_language="fr") + assert out.startswith("Error:") + assert "'language'" in out + assert "'exclude_language'" in out + assert len(respx.calls) == 0 + + +@respx.mock(assert_all_called=False) +async def test_market_mutex_q_in_meta_and_q_short_circuits(): + out = await get_market_news(q="apple", q_in_meta="earnings") + assert out.startswith("Error:") + assert "'q'" in out + assert "'q_in_meta'" in out + assert len(respx.calls) == 0 + + +@respx.mock +async def test_valid_one_per_group_still_passes_through_to_api(): + """Sanity check: a valid combination (one member per group) reaches + fetch() as before. Catches accidental over-zealous blocking.""" + respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + out = await get_latest_news( + q="bitcoin", + country="us", + category="technology", + language="en", + domain="reuters.com", + ) + assert "endpoint: latest" in out or "No latest articles" in out + + +# --------------------------------------------------------------------------- +# Step 2 — tool-level acceptance of LLM-natural input forms. +# Schema widening only matters if a tool actually accepts and forwards +# the new shapes. These tests verify each tool entry-point. +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_latest_news_accepts_bool_for_flags(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(image=True, video=False, full_content=True) + qs = dict(route.calls[0].request.url.params) + assert qs["image"] == "1" + assert qs["video"] == "0" + assert qs["full_content"] == "1" + + +@respx.mock +async def test_latest_news_accepts_bool_for_removeduplicate(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(removeduplicate=True) + qs = dict(route.calls[0].request.url.params) + assert qs["removeduplicate"] == "1" + + +@respx.mock +async def test_latest_news_removeduplicate_false_dropped(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(removeduplicate=False) + qs = dict(route.calls[0].request.url.params) + assert "removeduplicate" not in qs + + +@respx.mock +async def test_latest_news_accepts_list_for_country(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(country=["us", "gb", "in"]) + qs = dict(route.calls[0].request.url.params) + assert qs["country"] == "us,gb,in" + + +@respx.mock +async def test_latest_news_accepts_list_for_category(): + """CATEGORY_FILTER uses `list[CATEGORY_CODE]` so each item is + validated against the enum.""" + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(category=["technology", "science"]) + qs = dict(route.calls[0].request.url.params) + assert qs["category"] == "technology,science" + + +@respx.mock +async def test_latest_news_accepts_int_for_timeframe(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(timeframe=24) + qs = dict(route.calls[0].request.url.params) + assert qs["timeframe"] == "24" + + +@respx.mock +async def test_latest_news_accepts_string_for_timeframe_minutes(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(timeframe="90m") + qs = dict(route.calls[0].request.url.params) + assert qs["timeframe"] == "90m" + + +@respx.mock +async def test_crypto_news_accepts_list_for_coin(): + route = respx.get("https://newsdata.io/api/1/crypto").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_crypto_news(coin=["btc", "eth", "sol"]) + qs = dict(route.calls[0].request.url.params) + assert qs["coin"] == "btc,eth,sol" + + +@respx.mock +async def test_market_news_accepts_list_for_symbol(): + route = respx.get("https://newsdata.io/api/1/market").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_market_news(symbol=["AAPL", "MSFT"]) + qs = dict(route.calls[0].request.url.params) + assert qs["symbol"] == "AAPL,MSFT" + + +@respx.mock +async def test_news_sources_accepts_list_for_country(): + route = respx.get("https://newsdata.io/api/1/sources").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_news_sources(country=["us", "gb"]) + qs = dict(route.calls[0].request.url.params) + assert qs["country"] == "us,gb" + + +@respx.mock +async def test_latest_news_mixed_realistic_llm_call(): + """An LLM-style call: bools, lists, ints, all together.""" + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news( + q="bitcoin OR ethereum", + country=["us", "gb"], + language=["en"], + category=["technology", "business"], + image=True, + full_content=True, + size=10, + timeframe=24, + removeduplicate=True, + priority_domain="top", + ) + qs = dict(route.calls[0].request.url.params) + assert qs["q"] == "bitcoin OR ethereum" + assert qs["country"] == "us,gb" + assert qs["language"] == "en" + assert qs["category"] == "technology,business" + assert qs["image"] == "1" + assert qs["full_content"] == "1" + assert qs["size"] == "10" + assert qs["timeframe"] == "24" + assert qs["removeduplicate"] == "1" + assert qs["prioritydomain"] == "top" + + +# --------------------------------------------------------------------------- +# Step 3 — creator, datatype, sentiment_score, and the sentiment_score +# requires sentiment validator. Wired across latest, archive, market. +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_latest_news_accepts_creator_string_and_list(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(creator="jane doe") + assert dict(route.calls[0].request.url.params)["creator"] == "jane doe" + + await get_latest_news(creator=["john smith", "jane doe"]) + assert dict(route.calls[1].request.url.params)["creator"] == "john smith,jane doe" + + +@respx.mock +async def test_latest_news_accepts_datatype(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(datatype=["article", "video"]) + qs = dict(route.calls[0].request.url.params) + assert qs["datatype"] == "article,video" + + +@respx.mock +async def test_latest_news_sentiment_score_with_sentiment_passes(): + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_latest_news(sentiment="positive", sentiment_score=70) + qs = dict(route.calls[0].request.url.params) + assert qs["sentiment"] == "positive" + assert qs["sentiment_score"] == "70" + + +@respx.mock(assert_all_called=False) +async def test_latest_news_sentiment_score_without_sentiment_short_circuits(): + out = await get_latest_news(sentiment_score=50) + assert out.startswith("Error:") + assert "'sentiment_score'" in out + assert "'sentiment'" in out + # And the message tells the LLM what concrete fix to apply. + assert "'positive'" in out + assert len(respx.calls) == 0 + + +@respx.mock(assert_all_called=False) +async def test_archive_news_sentiment_score_without_sentiment_short_circuits(): + out = await get_archive_news(sentiment_score=50, from_date="2024-01-01") + assert out.startswith("Error:") + assert "'sentiment_score'" in out + assert "'sentiment'" in out + assert len(respx.calls) == 0 + + +@respx.mock +async def test_archive_news_sentiment_pair_passes(): + """archive previously lacked `sentiment` entirely; verify it now + accepts and forwards both halves of the pair.""" + route = respx.get("https://newsdata.io/api/1/archive").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_archive_news( + sentiment="negative", + sentiment_score=60, + from_date="2024-01-01", + ) + qs = dict(route.calls[0].request.url.params) + assert qs["sentiment"] == "negative" + assert qs["sentiment_score"] == "60" + + +@respx.mock +async def test_archive_news_accepts_creator_and_datatype(): + route = respx.get("https://newsdata.io/api/1/archive").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_archive_news( + creator=["ana lopez", "bao chen"], + datatype="article", + from_date="2024-01-01", + ) + qs = dict(route.calls[0].request.url.params) + assert qs["creator"] == "ana lopez,bao chen" + assert qs["datatype"] == "article" + + +@respx.mock +async def test_archive_news_accepts_tag_region_organization(): + """Regression: these params existed in the SDK and on the other + article tools but were missing from `get_archive_news`.""" + route = respx.get("https://newsdata.io/api/1/archive").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_archive_news( + from_date="2024-01-01", + tag=["climate", "blockchain"], + region="new york-united states of america", + organization=["tesla", "apple"], + ) + qs = dict(route.calls[0].request.url.params) + assert qs["tag"] == "climate,blockchain" + assert qs["region"] == "new york-united states of america" + assert qs["organization"] == "tesla,apple" + + +@respx.mock(assert_all_called=False) +async def test_market_news_sentiment_score_without_sentiment_short_circuits(): + out = await get_market_news(sentiment_score=80, symbol="NVDA") + assert out.startswith("Error:") + assert "'sentiment_score'" in out + assert "'sentiment'" in out + assert len(respx.calls) == 0 + + +@respx.mock +async def test_market_news_sentiment_pair_passes(): + route = respx.get("https://newsdata.io/api/1/market").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_market_news( + sentiment="positive", + sentiment_score=80, + symbol="NVDA", + ) + qs = dict(route.calls[0].request.url.params) + assert qs["sentiment"] == "positive" + assert qs["sentiment_score"] == "80" + assert qs["symbol"] == "NVDA" + + +@respx.mock +async def test_market_news_accepts_creator_and_datatype(): + route = respx.get("https://newsdata.io/api/1/market").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_market_news( + creator="bao chen", + datatype=["article", "video"], + ) + qs = dict(route.calls[0].request.url.params) + assert qs["creator"] == "bao chen" + assert qs["datatype"] == "article,video" + + +@respx.mock +async def test_sentiment_alone_still_works_without_score(): + """Regression: the validator must not block `sentiment` alone.""" + route = respx.get("https://newsdata.io/api/1/latest").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + out = await get_latest_news(sentiment="positive", q="bitcoin") + assert not out.startswith("Error:") + qs = dict(route.calls[0].request.url.params) + assert qs["sentiment"] == "positive" + assert "sentiment_score" not in qs + + +# --------------------------------------------------------------------------- +# Step 4 — count endpoints. Each tool hits its own URL, accepts date range, +# and renders buckets via format_counts. +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_news_counts_hits_count_endpoint(): + route = respx.get("https://newsdata.io/api/1/count").mock( + return_value=httpx.Response( + 200, + json={ + "status": "success", + "results": [ + {"dateTime": "2024-01-01 00:00:00", "count": 100}, + {"dateTime": "2024-01-02 00:00:00", "count": 150}, + ], + }, + ) + ) + out = await get_news_counts(from_date="2024-01-01", to_date="2024-01-02", q="bitcoin") + assert route.called + qs = dict(route.calls[0].request.url.params) + assert qs["from_date"] == "2024-01-01" + assert qs["to_date"] == "2024-01-02" + assert qs["q"] == "bitcoin" + assert "endpoint: count" in out + assert "buckets: 2" in out + assert "dateTime: 2024-01-01 00:00:00" in out + + +@respx.mock +async def test_news_counts_interval_param_sent(): + route = respx.get("https://newsdata.io/api/1/count").mock( + return_value=httpx.Response(200, json={"status": "success", "results": []}) + ) + await get_news_counts( + from_date="2024-01-01", + to_date="2024-01-31", + interval="hour", + ) + qs = dict(route.calls[0].request.url.params) + assert qs["interval"] == "hour" + + +@respx.mock(assert_all_called=False) +async def test_news_counts_mutex_short_circuits(): + out = await get_news_counts( + from_date="2024-01-01", + to_date="2024-01-31", + q="x", + q_in_title="y", + ) + assert out.startswith("Error:") + assert "'q'" in out + assert "'q_in_title'" in out + assert len(respx.calls) == 0 + + +@respx.mock(assert_all_called=False) +async def test_news_counts_sentiment_score_without_sentiment_short_circuits(): + out = await get_news_counts( + from_date="2024-01-01", + to_date="2024-01-31", + sentiment_score=50, + ) + assert out.startswith("Error:") + assert "'sentiment_score'" in out + assert "'sentiment'" in out + assert len(respx.calls) == 0 + + +@respx.mock +async def test_crypto_counts_hits_crypto_count_endpoint(): + route = respx.get("https://newsdata.io/api/1/crypto/count").mock( + return_value=httpx.Response( + 200, + json={ + "status": "success", + "results": [{"dateTime": "2024-01-01 00:00:00", "count": 42}], + }, + ) + ) + out = await get_crypto_counts( + from_date="2024-01-01", + to_date="2024-01-31", + coin=["btc", "eth"], + ) + assert route.called + qs = dict(route.calls[0].request.url.params) + assert qs["coin"] == "btc,eth" + assert "endpoint: crypto/count" in out + + +@respx.mock(assert_all_called=False) +async def test_crypto_counts_mutex_language_short_circuits(): + out = await get_crypto_counts( + from_date="2024-01-01", + to_date="2024-01-31", + language="en", + exclude_language="fr", + ) + assert out.startswith("Error:") + assert "'language'" in out + assert "'exclude_language'" in out + assert len(respx.calls) == 0 + + +@respx.mock +async def test_market_counts_hits_market_count_endpoint(): + route = respx.get("https://newsdata.io/api/1/market/count").mock( + return_value=httpx.Response( + 200, + json={ + "status": "success", + "results": [{"dateTime": "2024-01-01 00:00:00", "count": 12}], + }, + ) + ) + out = await get_market_counts( + from_date="2024-01-01", + to_date="2024-01-31", + symbol="AAPL", + interval="day", + ) + assert route.called + qs = dict(route.calls[0].request.url.params) + assert qs["symbol"] == "AAPL" + assert qs["interval"] == "day" + assert "endpoint: market/count" in out + + +@respx.mock(assert_all_called=False) +async def test_market_counts_sentiment_score_without_sentiment_short_circuits(): + out = await get_market_counts( + from_date="2024-01-01", + to_date="2024-01-31", + sentiment_score=80, + ) + assert out.startswith("Error:") + assert len(respx.calls) == 0 + + +@respx.mock +async def test_news_counts_renders_aggregate_dict_results(): + """When NewsData returns `results` as a dict (final page of pagination), + format_counts renders an aggregate block instead of bucket rows.""" + respx.get("https://newsdata.io/api/1/count").mock( + return_value=httpx.Response( + 200, + json={ + "status": "success", + "totalResults": 31, + "results": {"grand_total": 31000, "avg_per_day": 1000}, + }, + ) + ) + out = await get_news_counts(from_date="2024-01-01", to_date="2024-01-31") + assert "aggregate:" in out + assert "grand_total: 31000" in out + assert "buckets:" not in out + + +async def test_count_endpoints_require_dates(): + """from_date and to_date are required-by-signature on every count tool. + Calling without them should raise TypeError before any HTTP layer + sees the call.""" + import pytest + with pytest.raises(TypeError): + await get_news_counts() + with pytest.raises(TypeError): + await get_crypto_counts() + with pytest.raises(TypeError): + await get_market_counts() diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..626d425 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,228 @@ +"""Tests for the client-side mutex-group validator. + +These tests are pure-Python — no async, no HTTP — because the validator +returns a string instead of raising and doesn't touch the network. +""" +import pytest + +from newsdata_mcp.validators import ( + MUTEX_GROUPS, + check_mutex_groups, + check_sentiment_score_requires_sentiment, +) + +# ---------- No-violation cases ---------- + +def test_empty_kwargs_no_violation(): + assert check_mutex_groups({}) is None + + +def test_all_none_values_no_violation(): + assert check_mutex_groups({ + "q": None, + "q_in_title": None, + "q_in_meta": None, + "country": None, + "exclude_country": None, + }) is None + + +def test_one_per_group_no_violation(): + """A legal combination: one member per group, nothing conflicts.""" + assert check_mutex_groups({ + "q": "bitcoin", + "country": "us", + "category": "technology", + "language": "en", + "domain": "reuters.com", + }) is None + + +def test_unknown_kwargs_ignored(): + """Keys not in any mutex group are passed through without effect.""" + assert check_mutex_groups({ + "q": "bitcoin", + "size": 10, + "timezone": "UTC", + "completely_unknown_param": "value", + }) is None + + +# ---------- Group 1: q / q_in_title / q_in_meta ---------- + +def test_q_and_q_in_title_conflict(): + err = check_mutex_groups({"q": "a", "q_in_title": "b"}) + assert err is not None + assert "'q'" in err + assert "'q_in_title'" in err + + +def test_q_and_q_in_meta_conflict(): + err = check_mutex_groups({"q": "a", "q_in_meta": "b"}) + assert err is not None + assert "'q'" in err + assert "'q_in_meta'" in err + + +def test_q_in_title_and_q_in_meta_conflict(): + err = check_mutex_groups({"q_in_title": "a", "q_in_meta": "b"}) + assert err is not None + assert "'q_in_title'" in err + assert "'q_in_meta'" in err + + +def test_all_three_q_modes_conflict(): + err = check_mutex_groups({"q": "a", "q_in_title": "b", "q_in_meta": "c"}) + assert err is not None + for name in ("'q'", "'q_in_title'", "'q_in_meta'"): + assert name in err + + +# ---------- Groups 2-4: simple include/exclude pairs ---------- + +@pytest.mark.parametrize( + "kwargs,expected_names", + [ + ( + {"country": "us", "exclude_country": "gb"}, + ["'country'", "'exclude_country'"], + ), + ( + {"category": "tech", "exclude_category": "sports"}, + ["'category'", "'exclude_category'"], + ), + ( + {"language": "en", "exclude_language": "fr"}, + ["'language'", "'exclude_language'"], + ), + ], +) +def test_include_exclude_pairs_conflict(kwargs, expected_names): + err = check_mutex_groups(kwargs) + assert err is not None + for name in expected_names: + assert name in err + + +# ---------- Group 5: three-way domain mutex ---------- + +def test_domain_and_domainurl_conflict(): + err = check_mutex_groups({"domain": "x", "domainurl": "y.com"}) + assert err is not None + + +def test_domain_and_exclude_domain_conflict(): + err = check_mutex_groups({"domain": "x", "exclude_domain": "y"}) + assert err is not None + + +def test_domainurl_and_exclude_domain_conflict(): + err = check_mutex_groups({"domainurl": "y.com", "exclude_domain": "z"}) + assert err is not None + + +def test_all_three_domain_modes_conflict(): + err = check_mutex_groups({ + "domain": "x", + "domainurl": "y.com", + "exclude_domain": "z", + }) + assert err is not None + for name in ("'domain'", "'domainurl'", "'exclude_domain'"): + assert name in err + + +def test_mutex_groups_includes_three_way_domain(): + """Regression: the official SDK enforces a three-way domain mutex. + Earlier MCP docstrings only documented a two-way exclusion.""" + domain_group = next(g for g in MUTEX_GROUPS if "domain" in g) + assert "domain" in domain_group + assert "domainurl" in domain_group + assert "exclude_domain" in domain_group + + +# ---------- Error message shape ---------- + +def test_error_message_format_names_conflict_and_full_group(): + err = check_mutex_groups({"q": "a", "q_in_title": "b"}) + assert err is not None + # Names what conflicted. + assert "Cannot combine mutually exclusive parameters" in err + # Tells the LLM what the full set of options was. + assert "Pass only one of" in err + # The whole group is listed in the "allowed" clause, including the + # member that wasn't passed. + assert "'q_in_meta'" in err + # Explicit fix at the end. + assert "Omit the others" in err + + +def test_error_message_uses_python_kwarg_names_not_wire_names(): + """The LLM passes Python kwarg names; the error must reference those, + not NewsData's wire names (qInTitle, excludecountry, ...).""" + err = check_mutex_groups({"country": "us", "exclude_country": "gb"}) + assert err is not None + # Python kwarg name appears. + assert "'exclude_country'" in err + # Wire-name form must NOT appear. + assert "excludecountry" not in err + + +# ---------- First-group-wins ordering ---------- + +def test_first_violated_group_is_reported_when_multiple_violate(): + """If two groups both violate, only the first (in MUTEX_GROUPS order) + is reported. The LLM fixes that one and retries; if a second violation + is still present, the next call surfaces it. Trying to bundle all + violations into one message makes the parse harder.""" + err = check_mutex_groups({ + "q": "a", "q_in_title": "b", + "country": "us", "exclude_country": "gb", + }) + assert err is not None + # First group (q*) is reported; country/exclude_country pair isn't + # mentioned in the same message. + assert "'q'" in err + assert "'country'" not in err + + +# ---------- check_sentiment_score_requires_sentiment ---------- + +def test_sentiment_score_alone_returns_error(): + err = check_sentiment_score_requires_sentiment({"sentiment_score": 50}) + assert err is not None + assert "'sentiment_score'" in err + assert "'sentiment'" in err + + +def test_sentiment_score_with_sentiment_returns_none(): + assert check_sentiment_score_requires_sentiment({ + "sentiment_score": 50, + "sentiment": "positive", + }) is None + + +def test_sentiment_alone_returns_none(): + """`sentiment` without `sentiment_score` is always legal.""" + assert check_sentiment_score_requires_sentiment({"sentiment": "positive"}) is None + + +def test_neither_sentiment_nor_score_returns_none(): + assert check_sentiment_score_requires_sentiment({}) is None + assert check_sentiment_score_requires_sentiment({"q": "bitcoin"}) is None + + +def test_sentiment_score_zero_still_triggers_check(): + """`0` is a valid threshold and must trigger the check if `sentiment` + is missing. Tests that `is not None` (not truthiness) is used.""" + err = check_sentiment_score_requires_sentiment({"sentiment_score": 0}) + assert err is not None + + +def test_sentiment_score_error_message_suggests_concrete_fix(): + """Error message must name the labels the LLM can pass.""" + err = check_sentiment_score_requires_sentiment({"sentiment_score": 50}) + assert err is not None + assert "'positive'" in err + assert "'negative'" in err + assert "'neutral'" in err diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..184adb4 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1072 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, + { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, + { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, + { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, + { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, + { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[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 = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "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 = "idna" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, +] + +[[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 = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[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 = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[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.27.0" +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/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, +] + +[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 = "mypy" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/dc/7e6d49f04fca40b9dd5c752a51a432ffe67fb45200702bc9eee0cb4bbb26/mypy-2.0.0.tar.gz", hash = "sha256:1a9e3900ac5c40f1fe813506c7739da6e6f0eab2729067ebd94bfb0bbba53532", size = 3869036, upload-time = "2026-05-06T19:26:43.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/4b/f6cd12ef1eb63be1c342da3e8ca811d2280276177f6de4ef20cb2366d79b/mypy-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:660790551c988e69d8bf7a35c8b4149edeb22f4a339165702be843532e9dcdb5", size = 14756610, upload-time = "2026-05-06T19:26:19.221Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/67d09ca28bee21feaca264b2a680cf2d300bcc2071136ad064928324c843/mypy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a15bf92cd8781f8e72f69ffa7e30d1f434402d065ee1ecd5223ef2ef100f914", size = 13554270, upload-time = "2026-05-06T19:26:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/44718b5c6b1b5a27440ff2effe6a1be0fa2a190c0f4e2e21a83728416f95/mypy-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ff370b43d7def05bbcd2f5267f0bcda72dd6a552ef2ea9375b02d6fe06da270", size = 13924663, upload-time = "2026-05-06T19:21:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2b/bbb9cc5773f946846a7c340097e59bcf84095437dda0d56bb4f6cf1f6541/mypy-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37bd246590a018e5a11703b7b09c39d47ede3df5ba3fa863c5b8590b465beb01", size = 14946862, upload-time = "2026-05-06T19:24:23.023Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/e9318566f443a5130b4ff0ad3367ee6c4c4c49ff083fe5214a7318c18282/mypy-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cce87e92214fac8bf8feb8a680d0c1b6fb748d50e9b57fbb13e4b1d83a3ed19b", size = 15175090, upload-time = "2026-05-06T19:26:28.794Z" }, + { url = "https://files.pythonhosted.org/packages/67/65/2ec28c834f21e164c33bc296a7db538ad50c74f83e517c7a0be95ff6de86/mypy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e19e9cb69b66a4141009d24898259914fa2b71d026de0b46edf9fafdbf4fd46e", size = 11052899, upload-time = "2026-05-06T19:25:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/9e/72/d1ec625cfc9bd101c07a6834ef1f94e820296f8fdbad2eb03f50e0983f8c/mypy-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:b021614cb08d44785b025982163ec3c39c94bff766ead071fa9e82b4ef6f62cd", size = 9972935, upload-time = "2026-05-06T19:23:24.204Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c6/996a1e535e5d0d597c3b1460fc962733091f885f312e749350eb2ac10965/mypy-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ef5f581b61240d1cc629b12f8df6565ed6ffac0d82ed745eef7833222ab50b9", size = 14737259, upload-time = "2026-05-06T19:20:23.081Z" }, + { url = "https://files.pythonhosted.org/packages/94/c5/0f9460e26b77f434bd53f47d1ce32a3cd4580c92a5331fa5dfc059f9421a/mypy-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20e3470a165dbc249bdfbe8d1c5172727ef22688cffc279f8c3aa264ab9d4d9a", size = 13538377, upload-time = "2026-05-06T19:21:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/b2/3e/8ea2f8dd1e5c9c279fb3c28193bdb850adf4d3d8172880abad829eced609/mypy-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224ba142eee8b4d65d4db657cb1fc22abec30b135ded6ab297302ba1f62e505d", size = 13914264, upload-time = "2026-05-06T19:24:12.875Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/78bd3b8520f676acee9dab48ea71473e68f6d5cf14b59fbd800bea50a92b/mypy-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e879ad8a03908ff74d15e8a9b42bf049918e6798d52c011011f1873d0b5877e", size = 14926761, upload-time = "2026-05-06T19:20:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/61/ef/b52fa340522da3d22e669117c3b83155c2660f7cdc035856958fbfffb224/mypy-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5c15bcbd18d6fe927cc55c459597a3517d69cc3123f067be3b020010e115e", size = 15157014, upload-time = "2026-05-06T19:25:49.78Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/dde7614250c6d017936c7aa3bb63b9b52c7cfd298d3f1be9be45f307870b/mypy-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1a068acd7c9fb77e9f8923f1556f2f49d6d7895821121b8d97fa5642b9c52f5", size = 11067049, upload-time = "2026-05-06T19:21:16.116Z" }, + { url = "https://files.pythonhosted.org/packages/27/ec/1d6af4830a94a285442db19caa02f160cc1a255e4f324eec5458e6c2bafb/mypy-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:ef9d96da1ddffbc21f27d3939319b6846d12393baa17c4d2f3e81e040e73ce2c", size = 9967903, upload-time = "2026-05-06T19:22:15.52Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2c/6fefe954207860aed6eeb91776795e64a257d3ce0360862288984ce121f5/mypy-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c918c64e8ce36557851b0347f84eb12f1965d3a06813c36df253eb0c0afd1d82", size = 14729633, upload-time = "2026-05-06T19:24:53.383Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/d336f5b820af189eb0390cce21de62d264c0a4e64713dfbe81bfc4fc7739/mypy-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:301f1a8ccc7d79b542ee218b28bb49443a83e194eb3d10da63ff1649e5aa5d34", size = 13559524, upload-time = "2026-05-06T19:22:24.906Z" }, + { url = "https://files.pythonhosted.org/packages/af/a6/d7bb54fde1770f0484e5fbdbdce37a41e95ed0a1cd493ec60ead111e356c/mypy-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdf4ef489d44ce350bac3fd699907834e551d4c934e9cc862ef201215ab1558d", size = 13936018, upload-time = "2026-05-06T19:25:02.992Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ba/5be51316b91e6a6bf6e3a8adb3de500e7e1fb5bf9491743b8cbc81a34a2c/mypy-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cde2d0989f912fc850890f727d0d76495e7a6c5bdd9912a1efdb64952b4398d", size = 14910712, upload-time = "2026-05-06T19:25:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/e2c8c3b373e20ebfb66e6c83a99027fd67df4ec43b08879f74e822d2dc4c/mypy-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdf05693c231a14fe37dbfce192a3a1372c26a833af4a80f550547742952e719", size = 15141499, upload-time = "2026-05-06T19:20:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/12/36/07756f933e00416d912e35878cfcf89a593a3350a885691c0bb85ae0226a/mypy-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:73aee2da33a2237e66cbe84a94780e53599847e86bb3aa7b93e405e8cd9905f2", size = 11240511, upload-time = "2026-05-06T19:21:32.39Z" }, + { url = "https://files.pythonhosted.org/packages/70/05/79ac1f20f2397353f3845f7b8bb5d8006cda7c8ef9092f04f9de3c6135f2/mypy-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:1f6dcd8f39971f41edab2728c877c4ac8b50ad3c387ff2770423b79a05d23910", size = 10149336, upload-time = "2026-05-06T19:22:08.383Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/0db84e0ebbad6e99e566c68e4b465784f2a2294f7719e8db9d509ef23087/mypy-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a04e980b9275c76159da66c6e1723c7798306f9802b31bdaf9358d0c84030ce8", size = 15797362, upload-time = "2026-05-06T19:22:00.835Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a4/14cc0768164dd53bec48aa41a20270b18df9bf72aa5054278bf133608315/mypy-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:33f9cf4825469b2bc73c53ba55f6d9a9b4cdb60f9e6e228745581520f29b8771", size = 14635914, upload-time = "2026-05-06T19:23:43.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/48/d866a3e23b4dc5974c77d9cf65a435bf22de01a84dd4620917950e233960/mypy-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191675c3c7dc2a5c7722a035a6909c277f14046c5e4e02aa5fbf65f8524f08ad", size = 15270866, upload-time = "2026-05-06T19:22:34.756Z" }, + { url = "https://files.pythonhosted.org/packages/71/eb/de9ef94958eb2078a6b908ceb247757dc384d3a238d3bd6ed7d81de5eaf8/mypy-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3d26c4321a3b06fc9f04c741e0733af693f82d823f8e64e47b2e63b7f19fa84", size = 16093131, upload-time = "2026-05-06T19:23:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/ad/07/0ab2c1a9d26e90942612724cbd5788f16b7810c5dd39bfcf79286c6c4524/mypy-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bbcbc4d5917ca6ce12de70e051de7f533e3bf92d548b41a38a2232a6fe356525", size = 16330685, upload-time = "2026-05-06T19:21:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/46f85d1371a5be642dad263828118ae1efd536d91d8bd2000c68acff3920/mypy-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:dbc6ba6d40572ae49268531565793a8f07eac7fc65ad76d482c9b4c8765b6043", size = 12752017, upload-time = "2026-05-06T19:22:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e6/94ca48800cac19eb28a58188a768aaec0d16cac0f373915f073058ab0855/mypy-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:77926029dfcb7e1a3ecb0acb2ddbb24ca36be03f7d623e1759ad5376be8f6c01", size = 10527097, upload-time = "2026-05-06T19:20:58.973Z" }, + { url = "https://files.pythonhosted.org/packages/5c/14/fd0694aa594d6e9f9fd16ce821be2eff295197a273262ef56ddcc1388d68/mypy-2.0.0-py3-none-any.whl", hash = "sha256:8a92b2be3146b4fa1f062af7eb05574cbf3e6eb8e1f14704af1075423144e4e5", size = 2673434, upload-time = "2026-05-06T19:26:32.856Z" }, +] + +[[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 = "newsdata-mcp" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1,<1" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.27.0,<2" }, + { name = "pydantic", specifier = ">=2.7.0,<3" }, + { name = "python-dotenv", specifier = ">=1.2.2,<2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.10.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "respx", specifier = ">=0.21.1" }, + { name = "ruff", specifier = ">=0.6.0" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +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/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[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-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, +] + +[[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 = "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 = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[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 = "sse-starlette" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.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/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[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 = "uvicorn" +version = "0.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, +]