diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4f0b63e..bacc5ac 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -46,7 +46,11 @@ jobs: - name: Upload coverage to Codecov if: success() && github.repository_owner == 'withtwoemms' - run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + fail_ci_if_error: false - name: Archive coverage report if: always() @@ -61,6 +65,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + with: + submodules: true - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..821f7d5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/external/ucon-tools"] + path = docs/external/ucon-tools + url = https://github.com/withtwoemms/ucon-tools.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1cbb6..6960680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.4] - 2026-02-28 + +### Changed + +- MCP subpackage extracted to [ucon-tools](https://github.com/withtwoemms/ucon-tools) (#212) + - Install MCP server via `pip install ucon-tools[mcp]` + - Core ucon package no longer has MCP dependencies + - Namespace package support via `pkgutil.extend_path()` enables coexistence + +### Removed + +- `ucon.mcp` subpackage (moved to ucon-tools) +- `ucon-mcp` CLI entry point (now in ucon-tools) +- `mcp` optional dependency +- MCP documentation (moved to ucon-tools, sourced via submodule) + ## [0.9.3] - 2026-02-26 ### Added @@ -438,7 +454,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial commit -[Unreleased]: https://github.com/withtwoemms/ucon/compare/0.9.3...HEAD +[Unreleased]: https://github.com/withtwoemms/ucon/compare/0.9.4...HEAD +[0.9.4]: https://github.com/withtwoemms/ucon/compare/0.9.3...0.9.4 [0.9.3]: https://github.com/withtwoemms/ucon/compare/0.9.2...0.9.3 [0.9.2]: https://github.com/withtwoemms/ucon/compare/0.9.1...0.9.2 [0.9.1]: https://github.com/withtwoemms/ucon/compare/0.9.0...0.9.1 diff --git a/Makefile b/Makefile index 5406aca..f4e4d68 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ ${UV_VENV}: ${UV_INSTALLED} ${DEPS_INSTALLED}: pyproject.toml uv.lock | ${UV_VENV} @echo "${GREEN}Syncing dependencies into ${UV_VENV}...${RESET}" - @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv sync --python ${PYTHON} --extra test --extra pydantic --extra mcp + @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv sync --python ${PYTHON} --extra test --extra pydantic @touch ${DEPS_INSTALLED} .PHONY: venv @@ -74,7 +74,7 @@ install-test: ${UV_VENV} .PHONY: install install: ${UV_VENV} @echo "${GREEN}Installing with all extras into ${UV_VENV}...${RESET}" - @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv sync --python ${PYTHON} --extra test --extra pydantic --extra mcp + @UV_PROJECT_ENVIRONMENT=${UV_VENV} uv sync --python ${PYTHON} --extra test --extra pydantic # --- Testing --- .PHONY: test diff --git a/README.md b/README.md index 1f335bf..0f6e247 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ With extras: ```bash pip install ucon[pydantic] # Pydantic v2 integration -pip install ucon[mcp] # MCP server for AI agents +pip install ucon-tools[mcp] # MCP server for AI agents (separate package) ``` --- @@ -108,14 +108,18 @@ print(m.model_dump_json()) ### MCP Server for AI Agents -Configure in Claude Desktop: +Install `ucon-tools` and configure in Claude Desktop: + +```bash +pip install ucon-tools[mcp] +``` ```json { "mcpServers": { "ucon": { "command": "uvx", - "args": ["--from", "ucon[mcp]", "ucon-mcp"] + "args": ["--from", "ucon-tools[mcp]", "ucon-mcp"] } } } @@ -128,7 +132,7 @@ AI agents can then convert units, check dimensions, and perform factor-label cal ## Features - **Physical constants** — CODATA 2022 values with uncertainty propagation (`E = m * c**2`) -- **Custom constants** — Define domain-specific constants with uncertainty propagation via MCP or Python API +- **Custom constants** — Define domain-specific constants with uncertainty propagation - **String parsing** — `parse("9.81 m/s^2")` with uncertainty support (`1.234 ± 0.005 m`) - **Dimensional algebra** — Units combine through multiplication/division with automatic dimension tracking - **Scale prefixes** — Full SI (kilo, milli, micro, etc.) and binary (kibi, mebi) prefix support diff --git a/ROADMAP.md b/ROADMAP.md index c10b3cb..717daa0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,14 +47,15 @@ ucon is a dimensional analysis library for engineers building systems where unit | v0.9.1 | Logarithmic Units | Complete | | v0.9.2 | MCP Constants Tools | Complete | | v0.9.3 | Natural Units + MCP Session Fixes | Complete | +| v0.9.4 | MCP Extraction | Complete | | v0.10.0 | Scientific Computing | Planned | | v1.0.0 | API Stability | Planned | --- -## Current Version: **v0.9.3** (complete) +## Current Version: **v0.9.4** (complete) -Building on v0.9.2 baseline: +Building on v0.9.3 baseline: - `ucon.basis` (`Basis`, `BasisComponent`, `Vector`, `BasisTransform`, `BasisGraph`, `ConstantAwareBasisTransform`) - `ucon.bases` (standard bases: `SI`, `CGS`, `CGS_ESU`, `NATURAL`; standard transforms including `SI_TO_NATURAL`) - `ucon.dimension` (`Dimension` as frozen dataclass backed by basis-aware `Vector`) @@ -63,7 +64,6 @@ Building on v0.9.2 baseline: - `ucon.graph` (`ConversionGraph`, default graph, `get_default_graph()`, `using_graph()`, cross-basis conversion) - `ucon.units` (SI + imperial + information + angle + ratio units, callable syntax, `si` and `imperial` systems, `get_unit_by_name()`) - `ucon.pydantic` (`Number` type for Pydantic v2 models) -- `ucon.mcp` (`SessionState`, `DefaultSessionState` for injectable session management) - Callable unit API: `meter(5)`, `(mile / hour)(60)` - `Number.simplify()` for base-scale normalization - Pseudo-dimensions: `ANGLE`, `SOLID_ANGLE`, `RATIO`, `COUNT` with semantic isolation @@ -77,9 +77,8 @@ Building on v0.9.2 baseline: - Quantity string parsing: `parse("1.234 ± 0.005 m")` → `Number` with uncertainty - Physical constants: `Constant` class with CODATA 2022 values and uncertainty propagation - Logarithmic units: pH with concentration dimension, dBm, dBW, dBV, dBSPL -- MCP constants tools: `list_constants()`, `define_constant()` for AI agent access - Natural units: `NATURAL` basis with c=ℏ=k_B=1, `ConstantAwareBasisTransform` for non-square transforms -- MCP session persistence: lifespan-scoped session state across tool calls +- Namespace package support: `pkgutil.extend_path` enables coexistence with ucon-tools --- @@ -213,6 +212,9 @@ Building on v0.9.2 baseline: **Theme:** AI agent integration. +> **Note:** MCP functionality moved to [ucon-tools](https://github.com/withtwoemms/ucon-tools) in v0.9.4. +> Install via `pip install ucon-tools[mcp]`. + - [x] MCP server exposing unit conversion tools - [x] `convert` tool with dimensional validation - [x] `list_units`, `list_scales`, `list_dimensions` discovery tools @@ -655,6 +657,24 @@ Prerequisite for factor-label chains with countable items (tablets, doses). --- +## v0.9.4 — MCP Extraction (Complete) + +**Theme:** Separate MCP tooling into ucon-tools package. + +- [x] Extract `ucon.mcp` subpackage to `ucon-tools` repository +- [x] Add `pkgutil.extend_path()` for namespace package coexistence +- [x] Remove MCP optional dependency and entry point from pyproject.toml +- [x] Update documentation to reference `ucon-tools[mcp]` for MCP features +- [x] MCP docs moved to ucon-tools (sourced via git submodule) + +**Outcomes:** +- Core ucon package has no MCP dependencies (simpler install, broader compatibility) +- MCP tooling available via `pip install ucon-tools[mcp]` +- Namespace package allows both packages to coexist under `ucon.*` +- ucon-tools can iterate independently on AI agent features + +--- + ## v0.10.0 — Scientific Computing **Theme:** NumPy and DataFrame integration. diff --git a/docs/external/ucon-tools b/docs/external/ucon-tools new file mode 160000 index 0000000..ed67aac --- /dev/null +++ b/docs/external/ucon-tools @@ -0,0 +1 @@ +Subproject commit ed67aac169d589121eb5a117779cf35aaf814cca diff --git a/docs/guides/custom-units-and-graphs.md b/docs/guides/custom-units-and-graphs.md index fa02020..9bbf63d 100644 --- a/docs/guides/custom-units-and-graphs.md +++ b/docs/guides/custom-units-and-graphs.md @@ -3,7 +3,7 @@ ucon's unit system is extensible. You can define domain-specific units and conversions for aerospace, medicine, finance, or any specialized field. !!! tip "MCP Users" - For AI agent use cases, see [Custom Units via MCP](mcp-server/custom-units.md). + For AI agent use cases, see [Custom Units via MCP](../external/ucon-tools/docs/guides/mcp-server/custom-units.md). ## Unit Definition diff --git a/docs/guides/index.md b/docs/guides/index.md index 54c4d70..7ea50fa 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -4,9 +4,9 @@ Task-oriented guides for common use cases. ## MCP Server -- **[Overview](mcp-server/index.md)** - Setup and available tools -- **[Custom Units](mcp-server/custom-units.md)** - Define domain-specific units at runtime -- **[Registering Formulas](mcp-server/registering-formulas.md)** - Expose dimensionally-typed calculations to agents +- **[Overview](../external/ucon-tools/docs/guides/mcp-server/index.md)** - Setup and available tools +- **[Custom Units](../external/ucon-tools/docs/guides/mcp-server/custom-units.md)** - Define domain-specific units at runtime +- **[Registering Formulas](../external/ucon-tools/docs/guides/mcp-server/registering-formulas.md)** - Expose dimensionally-typed calculations to agents ## Integration Guides diff --git a/docs/guides/mcp-server/custom-units.md b/docs/guides/mcp-server/custom-units.md deleted file mode 100644 index 3512f7d..0000000 --- a/docs/guides/mcp-server/custom-units.md +++ /dev/null @@ -1,85 +0,0 @@ -# Custom Units via MCP - -Define domain-specific units at runtime through the MCP server. - -## Session Tools - -Use `define_unit` and `define_conversion` for persistent custom units within a session: - -```python -# Define a custom unit -define_unit(name="slug", dimension="mass", aliases=["slug"]) - -# Add conversion to standard units -define_conversion(src="slug", dst="kg", factor=14.5939) - -# Now use it -convert(value=1, from_unit="slug", to_unit="kg") -# → {"quantity": 14.5939, "unit": "kg", "dimension": "mass"} -``` - -Custom units persist until `reset_session()` is called. - -## Inline Definitions - -For one-off conversions without session state, pass definitions directly: - -```python -convert( - value=1, - from_unit="slug", - to_unit="kg", - custom_units=[ - {"name": "slug", "dimension": "mass", "aliases": ["slug"]} - ], - custom_edges=[ - {"src": "slug", "dst": "kg", "factor": 14.5939} - ] -) -``` - -Inline definitions are ephemeral — they don't modify the session graph. - -## When to Use Each - -| Approach | Use case | -|----------|----------| -| Session tools | Multiple conversions with same custom units | -| Inline definitions | One-off conversion, stateless recovery | - -## Unit Definition Schema - -```python -{ - "name": "slug", # Required: canonical name - "dimension": "mass", # Required: dimension name (from list_dimensions) - "aliases": ["slug"] # Optional: alternative names -} -``` - -## Edge Definition Schema - -```python -{ - "src": "slug", # Source unit name - "dst": "kg", # Destination unit name - "factor": 14.5939 # Multiplier: dst = src * factor -} -``` - -## Error Handling - -Invalid definitions return structured errors: - -```python -define_unit(name="bad", dimension="nonexistent") -# → { -# "error": "Unknown dimension: 'nonexistent'", -# "error_type": "unknown_dimension", -# "hints": ["Use list_dimensions() to see available dimensions"] -# } -``` - -## Python API - -For programmatic unit definition without MCP, see [Custom Units & Graphs](../custom-units-and-graphs.md). diff --git a/docs/guides/mcp-server/index.md b/docs/guides/mcp-server/index.md deleted file mode 100644 index 12034ba..0000000 --- a/docs/guides/mcp-server/index.md +++ /dev/null @@ -1,272 +0,0 @@ -# MCP Server - -ucon includes a [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that exposes unit conversion and dimensional analysis to AI agents like Claude. - -## Installation - -```bash -pip install ucon[mcp] -``` - -!!! note "Python 3.10+" - The MCP server requires Python 3.10 or higher. - -## Configuration - -### Claude Desktop - -Add to your `claude_desktop_config.json`: - -**Via uvx (recommended):** - -```json -{ - "mcpServers": { - "ucon": { - "command": "uvx", - "args": ["--from", "ucon[mcp]", "ucon-mcp"] - } - } -} -``` - -**Local development:** - -```json -{ - "mcpServers": { - "ucon": { - "command": "uv", - "args": ["run", "--directory", "/path/to/ucon", "--extra", "mcp", "ucon-mcp"] - } - } -} -``` - -### Claude Code / Cursor - -The same configuration works for other MCP-compatible AI tools. - -## Available Tools - -### `convert` - -Convert a value from one unit to another. - -```python -convert(value=5, from_unit="km", to_unit="mi") -# → {"quantity": 3.107, "unit": "mi", "dimension": "length"} -``` - -Supports: - -- Base units: `meter`, `m`, `second`, `s`, `gram`, `g` -- Scaled units: `km`, `mL`, `kg`, `MHz` -- Composite units: `m/s`, `kg*m/s^2`, `N*m` -- Exponents: `m^2`, `s^-1` (ASCII) or `m²`, `s⁻¹` (Unicode) - -### `compute` - -Perform multi-step factor-label calculations with dimensional tracking. - -```python -compute( - initial_value=154, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - {"value": 15, "numerator": "mg", "denominator": "kg*day"}, - {"value": 1, "numerator": "day", "denominator": "3 ea"}, - ] -) -# → {"quantity": 349.2, "unit": "mg/ea", "dimension": "mass/count", "steps": [...]} -``` - -Each step in the response shows intermediate quantity, unit, and dimension. - -### `list_units` - -Discover available units, optionally filtered by dimension. - -```python -list_units(dimension="length") -# → [{"name": "meter", "shorthand": "m", "dimension": "length", ...}, ...] -``` - -### `list_scales` - -List SI and binary prefixes. - -```python -list_scales() -# → [{"name": "kilo", "prefix": "k", "factor": 1000.0}, ...] -``` - -### `check_dimensions` - -Check if two units have compatible dimensions. - -```python -check_dimensions(unit_a="kg", unit_b="lb") -# → {"compatible": true, "dimension_a": "mass", "dimension_b": "mass"} - -check_dimensions(unit_a="kg", unit_b="m") -# → {"compatible": false, "dimension_a": "mass", "dimension_b": "length"} -``` - -### `list_dimensions` - -List available physical dimensions. - -```python -list_dimensions() -# → ["acceleration", "area", "energy", "force", "length", "mass", ...] -``` - -### `define_unit` - -Register a custom unit for the session. - -```python -define_unit(name="slug", dimension="mass", aliases=["slug"]) -# → {"success": true, "message": "Unit 'slug' registered..."} -``` - -### `define_conversion` - -Add a conversion edge between units. - -```python -define_conversion(src="slug", dst="kg", factor=14.5939) -# → {"success": true, "message": "Conversion edge 'slug' → 'kg' added..."} -``` - -### `list_constants` - -List available physical constants, optionally filtered by category. - -```python -list_constants() -# → [{"symbol": "c", "name": "speed of light in vacuum", "value": 299792458, ...}, ...] - -list_constants(category="exact") -# → [7 SI defining constants] - -list_constants(category="session") -# → [user-defined constants] -``` - -Categories: `"exact"` (7), `"derived"` (3), `"measured"` (7), `"session"` (user-defined). - -### `define_constant` - -Register a custom constant for the session. - -```python -define_constant( - symbol="vₛ", - name="speed of sound in dry air at 20°C", - value=343, - unit="m/s" -) -# → {"success": true, "symbol": "vₛ", ...} -``` - -### `reset_session` - -Clear custom units, conversions, and constants. - -```python -reset_session() -# → {"success": true, "message": "Session reset..."} -``` - -### `list_formulas` - -List registered domain formulas with dimensional constraints. - -```python -list_formulas() -# → [{"name": "bmi", "description": "Body Mass Index", "parameters": {"mass": "mass", "height": "length"}}] -``` - -### `call_formula` - -Invoke a registered formula with dimensionally-validated inputs. - -```python -call_formula( - name="bmi", - parameters={ - "mass": {"value": 70, "unit": "kg"}, - "height": {"value": 1.75, "unit": "m"} - } -) -# → {"formula": "bmi", "quantity": 22.86, "unit": "kg/m²", ...} -``` - -See [Registering Formulas](registering-formulas.md) for how to create formulas. - -## Error Recovery - -When conversions fail, the MCP server returns structured errors with suggestions: - -```python -convert(value=1, from_unit="kilogram", to_unit="meter") -# → { -# "error": "Dimension mismatch: mass ≠ length", -# "error_type": "dimension_mismatch", -# "likely_fix": "Use a mass unit like 'lb' or 'g'" -# } -``` - -For typos: - -```python -convert(value=1, from_unit="kilgoram", to_unit="kg") -# → { -# "error": "Unknown unit: 'kilgoram'", -# "error_type": "unknown_unit", -# "likely_fix": "Did you mean 'kilogram'?" -# } -``` - -## Custom Units (Inline) - -For one-off conversions without session state: - -```python -convert( - value=1, - from_unit="slug", - to_unit="kg", - custom_units=[{"name": "slug", "dimension": "mass", "aliases": ["slug"]}], - custom_edges=[{"src": "slug", "dst": "kg", "factor": 14.5939}] -) -``` - -## Example Conversation - -**User:** How many milligrams of ibuprofen should a 154 lb patient receive if the dose is 10 mg/kg? - -**Claude:** (calls `compute`) - -```python -compute( - initial_value=154, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - {"value": 10, "numerator": "mg", "denominator": "kg"}, - ] -) -``` - -**Result:** 698.4 mg - -The step trace shows dimensional consistency at each point, so Claude can verify the calculation is physically meaningful. - -## Guides - -- [Registering Formulas](registering-formulas.md) — Expose dimensionally-typed calculations to agents -- [Custom Units via MCP](custom-units.md) — Define domain-specific units at runtime diff --git a/docs/guides/mcp-server/registering-formulas.md b/docs/guides/mcp-server/registering-formulas.md deleted file mode 100644 index 4632c42..0000000 --- a/docs/guides/mcp-server/registering-formulas.md +++ /dev/null @@ -1,177 +0,0 @@ -# Registering Formulas - -ucon's formula system lets you expose dimensionally-typed calculations to AI agents via MCP. Agents discover formulas through `list_formulas()` and invoke them with `call_formula()`. - -## Basic Formula - -```python -from ucon import Number, Dimension, enforce_dimensions -from ucon.mcp.formulas import register_formula - -@register_formula("bmi", description="Body Mass Index") -@enforce_dimensions -def bmi( - mass: Number[Dimension.mass], - height: Number[Dimension.length], -) -> Number: - return mass / (height * height) -``` - -**Key points:** - -- `@register_formula` must be the outermost decorator -- `@enforce_dimensions` enables runtime dimension checking -- `Number[Dimension.X]` declares expected dimensions -- Dimension constraints are extracted and exposed via `list_formulas()` - -## Agent Discovery - -After registration, agents see the formula via MCP: - -```python -list_formulas() -# → [ -# { -# "name": "bmi", -# "description": "Body Mass Index", -# "parameters": {"mass": "mass", "height": "length"} -# } -# ] -``` - -## Agent Invocation - -Agents call formulas with structured parameters: - -```python -call_formula( - name="bmi", - parameters={ - "mass": {"value": 70, "unit": "kg"}, - "height": {"value": 1.75, "unit": "m"} - } -) -# → {"formula": "bmi", "quantity": 22.86, "unit": "kg/m²", ...} -``` - -## Mixed Constraints - -Not all parameters need dimensional constraints: - -```python -@register_formula("dosage", description="Weight-based medication dosage") -@enforce_dimensions -def dosage( - patient_mass: Number[Dimension.mass], - dose_per_kg: Number, # Unconstrained - doses_per_day: Number[Dimension.frequency], -) -> Number: - return patient_mass * dose_per_kg * doses_per_day -``` - -Unconstrained parameters show `null` in the schema: - -```python -list_formulas() -# → [{ -# "name": "dosage", -# "parameters": { -# "patient_mass": "mass", -# "dose_per_kg": null, -# "doses_per_day": "frequency" -# } -# }] -``` - -## Error Handling - -Dimension mismatches produce structured errors: - -```python -call_formula( - name="bmi", - parameters={ - "mass": {"value": 70, "unit": "m"}, # Wrong dimension - "height": {"value": 1.75, "unit": "m"} - } -) -# → { -# "error": "mass: expected dimension 'mass', got 'length'", -# "error_type": "dimension_mismatch", -# "parameter": "mass", -# "expected": "mass", -# "got": "length" -# } -``` - -## Registration Timing - -Formulas must be registered before the MCP server starts. Typical pattern: - -```python -# myapp/formulas.py -from ucon import Number, Dimension, enforce_dimensions -from ucon.mcp.formulas import register_formula - -@register_formula("my_formula", description="...") -@enforce_dimensions -def my_formula(...) -> Number: - ... -``` - -```python -# myapp/__init__.py or entry point -import myapp.formulas # Triggers registration - -from ucon.mcp.server import main -main() # Start MCP server with formulas registered -``` - -## Formula Names - -Formula names must be unique. Re-registering raises `ValueError`: - -```python -@register_formula("duplicate") -def first(): - pass - -@register_formula("duplicate") # Raises ValueError -def second(): - pass -``` - -## Without Dimension Constraints - -Formulas work without `@enforce_dimensions`, but lose pre-call validation: - -```python -@register_formula("simple_multiply") -def simple_multiply(x: Number, y: Number) -> Number: - return x * y -``` - -Parameters show `null` for all dimensions. Agents can still call the formula, but won't catch dimension errors until execution. - -## Testing Formulas - -Use `clear_formulas()` in tests to reset state: - -```python -import pytest -from ucon.mcp.formulas import clear_formulas, register_formula, get_formula - -@pytest.fixture(autouse=True) -def clean_registry(): - clear_formulas() - yield - clear_formulas() - -def test_my_formula(): - @register_formula("test") - def test_fn(x: Number) -> Number: - return x - - info = get_formula("test") - assert info is not None -``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 3c0d604..0ebe521 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -4,5 +4,5 @@ Lookup-oriented documentation for quick answers. - **[API](api.md)** - Auto-generated API reference - **[Units & Dimensions](units-and-dimensions.md)** - Complete unit table by dimension -- **[MCP Tools](mcp-tools.md)** - Tool schemas and response formats +- **[MCP Tools](../external/ucon-tools/docs/reference/mcp-tools.md)** - Tool schemas and response formats - **[Scales & Prefixes](scales-and-prefixes.md)** - SI and binary prefix tables diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md deleted file mode 100644 index 952c9e3..0000000 --- a/docs/reference/mcp-tools.md +++ /dev/null @@ -1,702 +0,0 @@ -# MCP Tools Reference - -Complete schema and response format documentation for ucon MCP server tools. - ---- - -## convert - -Convert a numeric value from one unit to another. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `value` | float | Yes | The numeric quantity to convert | -| `from_unit` | string | Yes | Source unit string | -| `to_unit` | string | Yes | Target unit string | -| `custom_units` | list[dict] | No | Inline unit definitions | -| `custom_edges` | list[dict] | No | Inline conversion edges | - -### Response Schema - -**Success: `ConversionResult`** - -```json -{ - "quantity": 3.107, - "unit": "mi", - "dimension": "length", - "uncertainty": null -} -``` - -**Error: `ConversionError`** - -```json -{ - "error": "Dimension mismatch: mass is not length", - "error_type": "dimension_mismatch", - "parameter": "to_unit", - "likely_fix": "Use a mass unit like 'lb' or 'g'" -} -``` - -### Examples - -```python -# Simple conversion -convert(value=5, from_unit="km", to_unit="mi") -# → {"quantity": 3.107, "unit": "mi", "dimension": "length"} - -# Composite units -convert(value=10, from_unit="m/s", to_unit="km/h") -# → {"quantity": 36.0, "unit": "km/h", "dimension": "velocity"} - -# With inline custom unit -convert( - value=1, - from_unit="slug", - to_unit="kg", - custom_units=[{"name": "slug", "dimension": "mass", "aliases": ["slug"]}], - custom_edges=[{"src": "slug", "dst": "kg", "factor": 14.5939}] -) -# → {"quantity": 14.5939, "unit": "kg", "dimension": "mass"} -``` - ---- - -## compute - -Perform multi-step factor-label calculations with dimensional tracking. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `initial_value` | float | Yes | Starting numeric quantity | -| `initial_unit` | string | Yes | Starting unit string | -| `factors` | list[dict] | Yes | Conversion factor chain | -| `custom_units` | list[dict] | No | Inline unit definitions | -| `custom_edges` | list[dict] | No | Inline conversion edges | - -**Factor dict schema:** - -| Field | Type | Description | -|-------|------|-------------| -| `value` | float | Numeric coefficient (default: 1.0) | -| `numerator` | string | Numerator unit string | -| `denominator` | string | Denominator unit string (may include numeric prefix) | - -### Response Schema - -**Success: `ComputeResult`** - -```json -{ - "quantity": 349.2, - "unit": "mg/ea", - "dimension": "mass/count", - "steps": [ - {"factor": "154 lb", "dimension": "mass", "unit": "lb"}, - {"factor": "(1 kg / 2.205 lb)", "dimension": "mass", "unit": "kg"}, - {"factor": "(15 mg / kg*day)", "dimension": "mass/time", "unit": "mg/d"}, - {"factor": "(1 day / 3 ea)", "dimension": "mass/count", "unit": "mg/ea"} - ] -} -``` - -### Examples - -```python -# Weight-based dosing calculation -compute( - initial_value=154, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - {"value": 15, "numerator": "mg", "denominator": "kg*day"}, - {"value": 1, "numerator": "day", "denominator": "3 ea"}, - ] -) -# → {"quantity": 349.2, "unit": "mg/ea", "dimension": "mass/count", "steps": [...]} - -# IV drip rate -compute( - initial_value=1000, - initial_unit="mL", - factors=[ - {"value": 15, "numerator": "drop", "denominator": "mL"}, - {"value": 1, "numerator": "1", "denominator": "8 hr"}, - {"value": 1, "numerator": "hr", "denominator": "60 min"}, - ], - custom_units=[{"name": "drop", "dimension": "count", "aliases": ["gtt"]}] -) -# → {"quantity": 31.25, "unit": "gtt/min", ...} -``` - ---- - -## list_units - -List available units, optionally filtered by dimension. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `dimension` | string | No | Filter by dimension name | - -### Response Schema - -**Success: `list[UnitInfo]`** - -```json -[ - { - "name": "meter", - "shorthand": "m", - "aliases": ["m"], - "dimension": "length", - "scalable": true - }, - { - "name": "foot", - "shorthand": "ft", - "aliases": ["ft", "feet"], - "dimension": "length", - "scalable": false - } -] -``` - -### Examples - -```python -# List all units -list_units() - -# Filter by dimension -list_units(dimension="mass") -# → [{"name": "gram", ...}, {"name": "kilogram", ...}, ...] -``` - ---- - -## list_scales - -List available scale prefixes. - -### Parameters - -None. - -### Response Schema - -**Success: `list[ScaleInfo]`** - -```json -[ - {"name": "peta", "prefix": "P", "factor": 1e15}, - {"name": "tera", "prefix": "T", "factor": 1e12}, - {"name": "giga", "prefix": "G", "factor": 1e9}, - {"name": "mega", "prefix": "M", "factor": 1e6}, - {"name": "kilo", "prefix": "k", "factor": 1000.0}, - {"name": "gibi", "prefix": "Gi", "factor": 1073741824.0}, - {"name": "mebi", "prefix": "Mi", "factor": 1048576.0}, - {"name": "kibi", "prefix": "Ki", "factor": 1024.0} -] -``` - ---- - -## list_dimensions - -List available physical dimensions. - -### Parameters - -None. - -### Response Schema - -**Success: `list[str]`** - -```json -[ - "acceleration", "amount_of_substance", "angle", "angular_momentum", - "area", "capacitance", "catalytic_activity", "charge", "conductance", - "conductivity", "count", "current", "density", "dynamic_viscosity", - "electric_field_strength", "energy", "entropy", "force", "frequency", - "gravitation", "illuminance", "inductance", "information", - "kinematic_viscosity", "length", "luminous_intensity", "magnetic_flux", - "magnetic_flux_density", "magnetic_permeability", "mass", "molar_mass", - "molar_volume", "momentum", "none", "permittivity", "power", "pressure", - "ratio", "resistance", "resistivity", "solid_angle", - "specific_heat_capacity", "temperature", "thermal_conductivity", "time", - "velocity", "voltage", "volume" -] -``` - ---- - -## check_dimensions - -Check if two units have compatible dimensions. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `unit_a` | string | Yes | First unit string | -| `unit_b` | string | Yes | Second unit string | - -### Response Schema - -**Success: `DimensionCheck`** - -```json -{ - "compatible": true, - "dimension_a": "mass", - "dimension_b": "mass" -} -``` - -### Examples - -```python -# Compatible units -check_dimensions(unit_a="kg", unit_b="lb") -# → {"compatible": true, "dimension_a": "mass", "dimension_b": "mass"} - -# Incompatible units -check_dimensions(unit_a="kg", unit_b="m") -# → {"compatible": false, "dimension_a": "mass", "dimension_b": "length"} -``` - ---- - -## define_unit - -Register a custom unit for the session. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `name` | string | Yes | Canonical unit name | -| `dimension` | string | Yes | Dimension name (use `list_dimensions()`) | -| `aliases` | list[str] | No | Shorthand symbols | - -### Response Schema - -**Success: `UnitDefinitionResult`** - -```json -{ - "success": true, - "name": "slug", - "dimension": "mass", - "aliases": ["slug"], - "message": "Unit 'slug' registered for session. Use define_conversion() to add conversion edges." -} -``` - -### Examples - -```python -define_unit(name="slug", dimension="mass", aliases=["slug"]) -define_unit(name="nautical_mile", dimension="length", aliases=["nmi", "NM"]) -``` - ---- - -## define_conversion - -Add a conversion edge between units. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `src` | string | Yes | Source unit name/alias | -| `dst` | string | Yes | Destination unit name/alias | -| `factor` | float | Yes | Conversion multiplier: `dst = src * factor` | - -### Response Schema - -**Success: `ConversionDefinitionResult`** - -```json -{ - "success": true, - "src": "slug", - "dst": "kg", - "factor": 14.5939, - "message": "Conversion edge 'slug' -> 'kg' (factor=14.5939) added to session." -} -``` - -### Examples - -```python -# After define_unit("slug", "mass", ["slug"]) -define_conversion(src="slug", dst="kg", factor=14.5939) - -# Now convert() can use the new unit -convert(value=1, from_unit="slug", to_unit="lb") -``` - ---- - -## list_constants - -List available physical constants, optionally filtered by category. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `category` | string | No | Filter by category: `"exact"`, `"derived"`, `"measured"`, `"session"`, `"all"` | - -### Response Schema - -**Success: `list[ConstantInfo]`** - -```json -[ - { - "symbol": "c", - "name": "speed of light in vacuum", - "value": 299792458, - "unit": "m/s", - "dimension": "velocity", - "uncertainty": null, - "is_exact": true, - "source": "CODATA 2022", - "category": "exact" - }, - { - "symbol": "G", - "name": "Newtonian constant of gravitation", - "value": 6.6743e-11, - "unit": "m³/(kg·s²)", - "dimension": "gravitation", - "uncertainty": 1.5e-15, - "is_exact": false, - "source": "CODATA 2022", - "category": "measured" - } -] -``` - -**Error: `ConstantError`** - -```json -{ - "error": "Unknown category: 'invalid'", - "error_type": "invalid_input", - "parameter": "category", - "hints": ["Valid categories: exact, derived, measured, session, all"] -} -``` - -### Categories - -| Category | Description | Count | -|----------|-------------|-------| -| `exact` | SI defining constants (2019 redefinition) | 7 | -| `derived` | Derived from exact constants | 3 | -| `measured` | Experimentally measured (with uncertainty) | 7 | -| `session` | User-defined via `define_constant()` | varies | - -### Examples - -```python -# List all constants -list_constants() -# → [{"symbol": "c", ...}, {"symbol": "h", ...}, ...] - -# Filter by category -list_constants(category="exact") -# → [{"symbol": "c", ...}, {"symbol": "h", ...}, ...] (7 exact constants) - -# Session constants only -list_constants(category="session") -# → [] (empty until define_constant() is called) -``` - ---- - -## define_constant - -Define a custom constant for the current session. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `symbol` | string | Yes | Short symbol (e.g., `"vₛ"`, `"Eg"`) | -| `name` | string | Yes | Full descriptive name | -| `value` | float | Yes | Numeric value in given units | -| `unit` | string | Yes | Unit string (e.g., `"m/s"`, `"J"`) | -| `uncertainty` | float | No | Standard uncertainty (None for exact) | -| `source` | string | No | Data source reference | - -### Response Schema - -**Success: `ConstantDefinitionResult`** - -```json -{ - "success": true, - "symbol": "vₛ", - "name": "speed of sound in dry air at 20°C", - "unit": "m/s", - "uncertainty": null, - "message": "Constant 'vₛ' registered for session." -} -``` - -**Error: `ConstantError`** - -```json -{ - "error": "Symbol 'c' is already defined as 'speed of light in vacuum'", - "error_type": "duplicate_symbol", - "parameter": "symbol", - "hints": ["Use a different symbol or use the built-in constant"] -} -``` - -### Error Types - -| Error Type | Description | -|------------|-------------| -| `duplicate_symbol` | Symbol exists in built-in or session constants | -| `invalid_unit` | Unit string cannot be parsed | -| `invalid_value` | Value is NaN, Inf, or non-numeric | - -### Examples - -```python -# Define speed of sound -define_constant( - symbol="vₛ", - name="speed of sound in dry air at 20°C", - value=343, - unit="m/s" -) - -# Define with uncertainty -define_constant( - symbol="g_local", - name="local gravitational acceleration", - value=9.81, - unit="m/s^2", - uncertainty=0.01, - source="measured on site" -) - -# Now visible in session constants -list_constants(category="session") -# → [{"symbol": "vₛ", ...}, {"symbol": "g_local", ...}] -``` - ---- - -## reset_session - -Clear all custom units, conversions, and constants. - -### Parameters - -None. - -### Response Schema - -**Success: `SessionResult`** - -```json -{ - "success": true, - "message": "Session reset. All custom units, conversions, and constants cleared." -} -``` - ---- - -## list_formulas - -List registered domain formulas with their dimensional constraints. - -### Parameters - -None. - -### Response Schema - -**Success: `list[FormulaInfoResponse]`** - -```json -[ - { - "name": "bmi", - "description": "Body Mass Index", - "parameters": { - "mass": "mass", - "height": "length" - } - } -] -``` - -### Examples - -```python -list_formulas() -# → [{"name": "bmi", "description": "Body Mass Index", "parameters": {"mass": "mass", "height": "length"}}] -``` - ---- - -## call_formula - -Invoke a registered formula with dimensionally-validated inputs. - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -| `name` | string | Yes | Formula name (from `list_formulas()`) | -| `parameters` | dict[str, dict] | Yes | Parameter values (see format below) | - -**Parameter value format:** - -```json -{ - "mass": {"value": 70, "unit": "kg"}, - "height": {"value": 1.75, "unit": "m"}, - "factor": {"value": 2.5} -} -``` - -- Use `{"value": ..., "unit": "..."}` for dimensioned quantities -- Use `{"value": ...}` for dimensionless quantities - -### Response Schema - -**Success: `FormulaResult`** - -```json -{ - "formula": "bmi", - "quantity": 22.86, - "unit": "kg/m²", - "dimension": "derived(mass/length²)", - "uncertainty": null -} -``` - -**Error: `FormulaError`** - -```json -{ - "error": "Missing required parameter: 'height'", - "error_type": "missing_parameter", - "formula": "bmi", - "parameter": "height", - "expected": "length", - "hints": ["Parameter 'height' expects dimension: length"] -} -``` - -### Error Types - -| Error Type | Description | -|------------|-------------| -| `unknown_formula` | Formula name not found in registry | -| `missing_parameter` | Required parameter not provided | -| `invalid_parameter` | Parameter format incorrect or unit unknown | -| `dimension_mismatch` | Parameter has wrong dimension | -| `execution_error` | Formula raised an exception | - -### Examples - -```python -# Call BMI formula -call_formula( - name="bmi", - parameters={ - "mass": {"value": 70, "unit": "kg"}, - "height": {"value": 1.75, "unit": "m"} - } -) -# → {"formula": "bmi", "quantity": 22.86, "unit": "kg/m²", ...} - -# Dimensionless parameter -call_formula( - name="scale_value", - parameters={ - "x": {"value": 10, "unit": "m"}, - "factor": {"value": 2.5} - } -) -# → {"formula": "scale_value", "quantity": 25.0, "unit": "m", ...} -``` - -### Registering Formulas - -Formulas are registered in Python code using `@register_formula`: - -```python -from ucon import Number, Dimension, enforce_dimensions -from ucon.mcp.formulas import register_formula - -@register_formula("bmi", description="Body Mass Index") -@enforce_dimensions -def bmi( - mass: Number[Dimension.mass], - height: Number[Dimension.length], -) -> Number: - return mass / (height * height) -``` - -The `@enforce_dimensions` decorator enables runtime dimension checking. Parameter constraints are extracted automatically and exposed via `list_formulas()`. - ---- - -## Error Types - -All tools may return `ConversionError` with these error types: - -| Error Type | Description | Example | -|------------|-------------|---------| -| `unknown_unit` | Unit string not recognized | `"kilgoram"` typo | -| `dimension_mismatch` | Units have incompatible dimensions | kg to m | -| `no_conversion_path` | No edge path between units | Custom unit without edge | -| `invalid_input` | Malformed parameter | Missing required field | -| `computation_error` | Runtime calculation failure | Division by zero | - -### Error Response Schema - -```json -{ - "error": "Human-readable error message", - "error_type": "unknown_unit", - "parameter": "from_unit", - "step": 2, - "likely_fix": "Did you mean 'kilogram'?", - "hints": ["Check spelling", "Use list_units() to see available units"] -} -``` - -| Field | Type | Description | -|-------|------|-------------| -| `error` | string | Human-readable message | -| `error_type` | string | Machine-readable error category | -| `parameter` | string | Which parameter caused the error | -| `step` | int | For compute(), which factor step failed (0-indexed) | -| `likely_fix` | string | Suggested correction (typos, dimension swaps) | -| `hints` | list[str] | Additional guidance | diff --git a/mkdocs.yml b/mkdocs.yml index dcb8f1d..87caea4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,6 +39,7 @@ extra_css: exclude_docs: | internal/ + external/ucon-tools/*.* nav: - Home: index.md @@ -55,9 +56,9 @@ nav: - Custom Units & Graphs: guides/custom-units-and-graphs.md - Natural Units: guides/natural-units.md - MCP Server: - - guides/mcp-server/index.md - - Custom Units: guides/mcp-server/custom-units.md - - Registering Formulas: guides/mcp-server/registering-formulas.md + - external/ucon-tools/docs/guides/mcp-server/index.md + - Custom Units: external/ucon-tools/docs/guides/mcp-server/custom-units.md + - Registering Formulas: external/ucon-tools/docs/guides/mcp-server/registering-formulas.md - Domain Walkthroughs: - guides/domain-walkthroughs/index.md - Nursing Dosage: guides/domain-walkthroughs/nursing-dosage.md @@ -65,7 +66,7 @@ nav: - reference/index.md - API: reference/api.md - Units & Dimensions: reference/units-and-dimensions.md - - MCP Tools: reference/mcp-tools.md + - MCP Tools: external/ucon-tools/docs/reference/mcp-tools.md - Scales & Prefixes: reference/scales-and-prefixes.md - Architecture: - architecture/index.md diff --git a/pyproject.toml b/pyproject.toml index 521d570..24b4880 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,12 +42,8 @@ Repository = "https://github.com/withtwoemms/ucon" [project.optional-dependencies] test = ["coverage[toml]>=5.5", "pytest>=7.0"] pydantic = ["pydantic>=2.0"] -mcp = ["mcp>=1.0; python_version >= '3.10'"] docs = ["mkdocs-material", "mkdocstrings[python]"] -[project.scripts] -ucon-mcp = "ucon.mcp.server:main" - # ----------------------------------------------------------------------------- # Build System # ----------------------------------------------------------------------------- @@ -57,7 +53,7 @@ requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [tool.setuptools] -packages = ["ucon", "ucon.mcp"] +packages = ["ucon"] [tool.setuptools_scm] # Version derived from git tags diff --git a/tests/ucon/mcp/__init__.py b/tests/ucon/mcp/__init__.py deleted file mode 100644 index 81af0ff..0000000 --- a/tests/ucon/mcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# MCP server tests diff --git a/tests/ucon/mcp/conftest.py b/tests/ucon/mcp/conftest.py deleted file mode 100644 index e7bc628..0000000 --- a/tests/ucon/mcp/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 - -"""MCP test configuration - requires Python 3.10+.""" - -import sys - -import pytest - -# Skip all MCP tests on Python < 3.10 -if sys.version_info < (3, 10): - collect_ignore_glob = ["test_*.py"] diff --git a/tests/ucon/mcp/test_constants.py b/tests/ucon/mcp/test_constants.py deleted file mode 100644 index cef7de3..0000000 --- a/tests/ucon/mcp/test_constants.py +++ /dev/null @@ -1,267 +0,0 @@ -# Copyright 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 - -"""Tests for MCP constants tools (v0.9.2).""" - -import sys -import pytest - - -# Skip MCP tests on Python < 3.10 (FastMCP requires 3.10+) -pytestmark = pytest.mark.skipif( - sys.version_info < (3, 10), - reason="MCP requires Python 3.10+", -) - - -class TestListConstants: - """Tests for list_constants tool.""" - - def test_returns_list(self): - from ucon.mcp.server import list_constants - result = list_constants() - assert isinstance(result, list) - assert len(result) >= 17 # Built-in constants - - def test_filter_exact(self): - from ucon.mcp.server import list_constants - result = list_constants(category="exact") - assert isinstance(result, list) - for const in result: - assert const.is_exact - assert const.category == "exact" - assert len(result) == 7 - - def test_filter_derived(self): - from ucon.mcp.server import list_constants - result = list_constants(category="derived") - assert isinstance(result, list) - for const in result: - assert const.category == "derived" - assert len(result) == 3 - - def test_filter_measured(self): - from ucon.mcp.server import list_constants - result = list_constants(category="measured") - assert isinstance(result, list) - for const in result: - assert not const.is_exact - assert const.category == "measured" - assert len(result) == 7 - - def test_filter_session_empty_initially(self): - from ucon.mcp.server import list_constants, reset_session - reset_session() - result = list_constants(category="session") - assert isinstance(result, list) - assert len(result) == 0 - - def test_filter_invalid_category(self): - from ucon.mcp.server import list_constants, ConstantError - result = list_constants(category="invalid") - assert isinstance(result, ConstantError) - assert result.error_type == "invalid_input" - - def test_includes_speed_of_light(self): - from ucon.mcp.server import list_constants - result = list_constants() - symbols = [c.symbol for c in result] - assert "c" in symbols - - def test_includes_gravitational_constant(self): - from ucon.mcp.server import list_constants - result = list_constants() - symbols = [c.symbol for c in result] - assert "G" in symbols - - def test_constant_info_has_all_fields(self): - from ucon.mcp.server import list_constants - result = list_constants() - const = result[0] - assert hasattr(const, 'symbol') - assert hasattr(const, 'name') - assert hasattr(const, 'value') - assert hasattr(const, 'unit') - assert hasattr(const, 'dimension') - assert hasattr(const, 'uncertainty') - assert hasattr(const, 'is_exact') - assert hasattr(const, 'source') - assert hasattr(const, 'category') - - -class TestDefineConstant: - """Tests for define_constant tool.""" - - def test_define_success(self): - from ucon.mcp.server import define_constant, reset_session, ConstantDefinitionResult - reset_session() - result = define_constant( - symbol="test_vs", - name="speed of sound", - value=343, - unit="m/s", - ) - assert isinstance(result, ConstantDefinitionResult) - assert result.success - assert result.symbol == "test_vs" - - def test_duplicate_builtin_symbol_fails(self): - from ucon.mcp.server import define_constant, ConstantError - result = define_constant( - symbol="c", - name="my speed", - value=300000000, - unit="m/s", - ) - assert isinstance(result, ConstantError) - assert result.error_type == "duplicate_symbol" - - def test_duplicate_session_symbol_fails(self): - from ucon.mcp.server import define_constant, reset_session, ConstantError - reset_session() - # Define first time - define_constant( - symbol="test_dup", - name="first", - value=1, - unit="m", - ) - # Try to define again - result = define_constant( - symbol="test_dup", - name="second", - value=2, - unit="m", - ) - assert isinstance(result, ConstantError) - assert result.error_type == "duplicate_symbol" - - def test_invalid_unit_fails(self): - from ucon.mcp.server import define_constant, reset_session, ConstantError - reset_session() - result = define_constant( - symbol="test_Y", - name="Y", - value=1, - unit="invalid_unit_xyz", - ) - assert isinstance(result, ConstantError) - assert result.error_type == "invalid_unit" - - def test_nan_value_fails(self): - import math - from ucon.mcp.server import define_constant, reset_session, ConstantError - reset_session() - result = define_constant( - symbol="test_nan", - name="NaN constant", - value=math.nan, - unit="m", - ) - assert isinstance(result, ConstantError) - assert result.error_type == "invalid_value" - - def test_inf_value_fails(self): - import math - from ucon.mcp.server import define_constant, reset_session, ConstantError - reset_session() - result = define_constant( - symbol="test_inf", - name="Inf constant", - value=math.inf, - unit="m", - ) - assert isinstance(result, ConstantError) - assert result.error_type == "invalid_value" - - def test_negative_uncertainty_fails(self): - from ucon.mcp.server import define_constant, reset_session, ConstantError - reset_session() - result = define_constant( - symbol="test_neg_unc", - name="negative uncertainty", - value=1.0, - unit="m", - uncertainty=-0.1, - ) - assert isinstance(result, ConstantError) - assert result.error_type == "invalid_value" - - def test_with_uncertainty(self): - from ucon.mcp.server import define_constant, reset_session, ConstantDefinitionResult - reset_session() - result = define_constant( - symbol="test_unc", - name="with uncertainty", - value=1.0, - unit="m", - uncertainty=0.01, - ) - assert isinstance(result, ConstantDefinitionResult) - assert result.success - assert result.uncertainty == 0.01 - - def test_defined_constant_appears_in_session_list(self): - from ucon.mcp.server import define_constant, list_constants, reset_session - reset_session() - define_constant( - symbol="test_sess", - name="session constant", - value=42, - unit="kg", - ) - result = list_constants(category="session") - assert len(result) == 1 - assert result[0].symbol == "test_sess" - assert result[0].category == "session" - - -class TestResetSession: - """Tests for reset_session clearing constants.""" - - def test_reset_clears_session_constants(self): - from ucon.mcp.server import define_constant, list_constants, reset_session - reset_session() - # Define a constant - define_constant( - symbol="test_clear", - name="to be cleared", - value=1, - unit="m", - ) - # Verify it exists - result = list_constants(category="session") - assert len(result) == 1 - - # Reset - reset_session() - - # Verify it's gone - result = list_constants(category="session") - assert len(result) == 0 - - -class TestConstantsCategoryCounts: - """Tests for correct counts of constants by category.""" - - def test_total_builtin_constants(self): - from ucon.mcp.server import list_constants - exact = list_constants(category="exact") - derived = list_constants(category="derived") - measured = list_constants(category="measured") - total = len(exact) + len(derived) + len(measured) - assert total == 17 - - def test_exact_constants_are_exact(self): - from ucon.mcp.server import list_constants - exact = list_constants(category="exact") - for const in exact: - assert const.uncertainty is None - assert const.is_exact is True - - def test_measured_constants_have_uncertainty(self): - from ucon.mcp.server import list_constants - measured = list_constants(category="measured") - for const in measured: - assert const.uncertainty is not None - assert const.is_exact is False diff --git a/tests/ucon/mcp/test_formulas.py b/tests/ucon/mcp/test_formulas.py deleted file mode 100644 index b4c591a..0000000 --- a/tests/ucon/mcp/test_formulas.py +++ /dev/null @@ -1,370 +0,0 @@ -# Copyright 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 - -"""Tests for formula registry and schema introspection.""" - -import pytest - -from ucon import Dimension, Number, enforce_dimensions -from ucon.mcp.formulas import ( - FormulaInfo, - register_formula, - list_formulas, - get_formula, - clear_formulas, -) -from ucon.mcp.schema import extract_dimension_constraints - - -@pytest.fixture(autouse=True) -def clean_registry(): - """Clear formula registry before and after each test.""" - clear_formulas() - yield - clear_formulas() - - -# ----------------------------------------------------------------------------- -# Schema Introspection Tests -# ----------------------------------------------------------------------------- - - -class TestExtractDimensionConstraints: - """Tests for extract_dimension_constraints().""" - - def test_extracts_single_constraint(self): - @enforce_dimensions - def measure(length: Number[Dimension.length]) -> Number: - return length - - constraints = extract_dimension_constraints(measure) - assert constraints == {'length': 'length'} - - def test_extracts_multiple_constraints(self): - @enforce_dimensions - def speed( - distance: Number[Dimension.length], - time: Number[Dimension.time], - ) -> Number: - return distance / time - - constraints = extract_dimension_constraints(speed) - assert constraints == {'distance': 'length', 'time': 'time'} - - def test_unconstrained_params_are_none(self): - @enforce_dimensions - def mixed( - mass: Number[Dimension.mass], - factor: Number, # No dimension constraint - ) -> Number: - return mass * factor - - constraints = extract_dimension_constraints(mixed) - assert constraints == {'mass': 'mass', 'factor': None} - - def test_handles_unwrapped_function(self): - """Works on functions without @enforce_dimensions.""" - def bare(distance: Number[Dimension.length]) -> Number: - return distance - - constraints = extract_dimension_constraints(bare) - assert constraints == {'distance': 'length'} - - def test_handles_no_annotations(self): - def untyped(x, y): - return x + y - - constraints = extract_dimension_constraints(untyped) - assert constraints == {} - - def test_excludes_return_annotation(self): - @enforce_dimensions - def with_return( - time: Number[Dimension.time], - ) -> Number[Dimension.time]: - return time - - constraints = extract_dimension_constraints(with_return) - assert 'return' not in constraints - assert constraints == {'time': 'time'} - - -# ----------------------------------------------------------------------------- -# Formula Registry Tests -# ----------------------------------------------------------------------------- - - -class TestRegisterFormula: - """Tests for @register_formula decorator.""" - - def test_registers_formula(self): - @register_formula("test_formula", description="A test formula") - @enforce_dimensions - def test_fn(x: Number[Dimension.length]) -> Number: - return x - - info = get_formula("test_formula") - assert info is not None - assert info.name == "test_formula" - assert info.description == "A test formula" - assert info.parameters == {'x': 'length'} - assert info.fn is test_fn - - def test_returns_original_function(self): - @register_formula("identity_test") - def original(x: Number) -> Number: - return x - - # Decorator should return the function unchanged - result = original(Number(5)) - assert result.quantity == 5 - - def test_duplicate_name_raises(self): - @register_formula("duplicate") - def first(): - pass - - with pytest.raises(ValueError, match="already registered"): - @register_formula("duplicate") - def second(): - pass - - def test_empty_description_default(self): - @register_formula("no_desc") - def nodesc(): - pass - - info = get_formula("no_desc") - assert info.description == "" - - -class TestListFormulas: - """Tests for list_formulas().""" - - def test_empty_registry(self): - assert list_formulas() == [] - - def test_returns_all_formulas(self): - @register_formula("alpha") - def a(): - pass - - @register_formula("beta") - def b(): - pass - - formulas = list_formulas() - assert len(formulas) == 2 - names = [f.name for f in formulas] - assert "alpha" in names - assert "beta" in names - - def test_sorted_by_name(self): - @register_formula("zebra") - def z(): - pass - - @register_formula("apple") - def a(): - pass - - formulas = list_formulas() - assert formulas[0].name == "apple" - assert formulas[1].name == "zebra" - - -class TestGetFormula: - """Tests for get_formula().""" - - def test_returns_none_if_not_found(self): - assert get_formula("nonexistent") is None - - def test_returns_formula_info(self): - @register_formula("findme", description="Find this") - def find(): - pass - - info = get_formula("findme") - assert isinstance(info, FormulaInfo) - assert info.name == "findme" - assert info.description == "Find this" - - -class TestClearFormulas: - """Tests for clear_formulas().""" - - def test_clears_all(self): - @register_formula("temp") - def temp(): - pass - - assert len(list_formulas()) == 1 - clear_formulas() - assert len(list_formulas()) == 0 - - -# ----------------------------------------------------------------------------- -# Integration Tests -# ----------------------------------------------------------------------------- - - -class TestFormulaIntegration: - """End-to-end tests for formula registration and introspection.""" - - def test_medical_formula(self): - """Test a realistic medical formula with multiple dimensions.""" - @register_formula("bmi", description="Body Mass Index") - @enforce_dimensions - def bmi( - mass: Number[Dimension.mass], - height: Number[Dimension.length], - ) -> Number: - return mass / (height * height) - - info = get_formula("bmi") - assert info.parameters == {'mass': 'mass', 'height': 'length'} - - def test_formula_with_mixed_constraints(self): - """Test formula with both constrained and unconstrained params.""" - @register_formula("dosage", description="Calculate medication dosage") - @enforce_dimensions - def dosage( - patient_mass: Number[Dimension.mass], - dose_per_kg: Number, # Unconstrained (could be mg/kg) - frequency: Number[Dimension.frequency], - ) -> Number: - return patient_mass * dose_per_kg * frequency - - info = get_formula("dosage") - assert info.parameters == { - 'patient_mass': 'mass', - 'dose_per_kg': None, - 'frequency': 'frequency', - } - - -# ----------------------------------------------------------------------------- -# MCP Tool Tests -# ----------------------------------------------------------------------------- - - -class TestCallFormula: - """Tests for call_formula MCP tool.""" - - def test_unknown_formula(self): - from ucon.mcp.server import call_formula, FormulaError - - result = call_formula("nonexistent", {}) - assert isinstance(result, FormulaError) - assert result.error_type == "unknown_formula" - assert "nonexistent" in result.error - - def test_missing_parameter(self): - from ucon.mcp.server import call_formula, FormulaError - - @register_formula("needs_params") - @enforce_dimensions - def needs_params(x: Number[Dimension.length]) -> Number: - return x - - result = call_formula("needs_params", {}) - assert isinstance(result, FormulaError) - assert result.error_type == "missing_parameter" - assert result.parameter == "x" - - def test_invalid_parameter_format(self): - from ucon.mcp.server import call_formula, FormulaError - - @register_formula("simple") - def simple(x: Number) -> Number: - return x - - # Pass a non-dict value - result = call_formula("simple", {"x": 5.0}) - assert isinstance(result, FormulaError) - assert result.error_type == "invalid_parameter" - - def test_missing_value_key(self): - from ucon.mcp.server import call_formula, FormulaError - - @register_formula("simple2") - def simple2(x: Number) -> Number: - return x - - result = call_formula("simple2", {"x": {"unit": "m"}}) - assert isinstance(result, FormulaError) - assert result.error_type == "invalid_parameter" - assert "value" in result.error - - def test_unknown_unit(self): - from ucon.mcp.server import call_formula, FormulaError - - @register_formula("with_unit") - def with_unit(x: Number) -> Number: - return x - - result = call_formula("with_unit", {"x": {"value": 5, "unit": "foobar"}}) - assert isinstance(result, FormulaError) - assert result.error_type == "invalid_parameter" - assert "foobar" in result.error - - def test_dimension_mismatch(self): - from ucon.mcp.server import call_formula, FormulaError - - @register_formula("length_only") - @enforce_dimensions - def length_only(x: Number[Dimension.length]) -> Number: - return x - - # Pass mass instead of length - result = call_formula("length_only", {"x": {"value": 5, "unit": "kg"}}) - assert isinstance(result, FormulaError) - assert result.error_type == "dimension_mismatch" - - def test_successful_call(self): - from ucon.mcp.server import call_formula, FormulaResult - - @register_formula("double_length") - @enforce_dimensions - def double_length(x: Number[Dimension.length]) -> Number: - return x * Number(2) - - result = call_formula("double_length", {"x": {"value": 5, "unit": "m"}}) - assert isinstance(result, FormulaResult) - assert result.formula == "double_length" - assert result.quantity == 10.0 - assert result.unit == "m" - assert result.dimension == "length" - - def test_dimensionless_parameter(self): - from ucon.mcp.server import call_formula, FormulaResult - - @register_formula("scale_it") - def scale_it(x: Number, factor: Number) -> Number: - return x * factor - - result = call_formula("scale_it", { - "x": {"value": 5, "unit": "m"}, - "factor": {"value": 3} - }) - assert isinstance(result, FormulaResult) - assert result.quantity == 15.0 - - def test_composite_unit_result(self): - from ucon.mcp.server import call_formula, FormulaResult - - @register_formula("velocity") - @enforce_dimensions - def velocity( - distance: Number[Dimension.length], - time: Number[Dimension.time] - ) -> Number: - return distance / time - - result = call_formula("velocity", { - "distance": {"value": 100, "unit": "m"}, - "time": {"value": 10, "unit": "s"} - }) - assert isinstance(result, FormulaResult) - assert result.quantity == 10.0 - assert result.unit == "m/s" diff --git a/tests/ucon/mcp/test_server.py b/tests/ucon/mcp/test_server.py deleted file mode 100644 index 1a1dcc7..0000000 --- a/tests/ucon/mcp/test_server.py +++ /dev/null @@ -1,1728 +0,0 @@ -# © 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 -# See the LICENSE file for details. - -""" -Tests for ucon MCP server. - -Tests the tool functions directly without running the full MCP server. -These tests are skipped if the mcp package is not installed. -""" - -import unittest - -from ucon import Dimension, units -from ucon.core import Scale -from ucon.dimension import all_dimensions - - -class TestConvertTool(unittest.TestCase): - """Test the convert tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import convert, ConversionResult - cls.convert = staticmethod(convert) - cls.ConversionResult = ConversionResult - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_simple_conversion(self): - """Test converting between simple units.""" - result = self.convert(1000, "m", "km") - self.assertAlmostEqual(result.quantity, 1.0) - self.assertEqual(result.dimension, "length") - - def test_scaled_unit_source(self): - """Test conversion from scaled unit.""" - result = self.convert(5, "km", "m") - self.assertAlmostEqual(result.quantity, 5000.0) - - def test_scaled_unit_target(self): - """Test conversion to scaled unit.""" - result = self.convert(500, "g", "kg") - self.assertAlmostEqual(result.quantity, 0.5) - - def test_composite_unit(self): - """Test conversion with composite units.""" - result = self.convert(1, "m/s", "km/h") - self.assertAlmostEqual(result.quantity, 3.6) - - def test_composite_ascii_notation(self): - """Test composite unit with ASCII notation.""" - result = self.convert(9.8, "m/s^2", "m/s^2") - self.assertAlmostEqual(result.quantity, 9.8) - - def test_returns_conversion_result(self): - """Test that convert returns ConversionResult model.""" - result = self.convert(100, "cm", "m") - self.assertIsInstance(result, self.ConversionResult) - self.assertIsNotNone(result.unit) - self.assertIsNotNone(result.dimension) - - def test_uncertainty_none_by_default(self): - """Test that uncertainty is None when not provided.""" - result = self.convert(1, "m", "ft") - self.assertIsNone(result.uncertainty) - - -class TestConvertToolErrors(unittest.TestCase): - """Test error handling in the convert tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import convert - from ucon.mcp.suggestions import ConversionError - cls.convert = staticmethod(convert) - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_unknown_source_unit(self): - """Test that unknown source unit returns ConversionError.""" - result = self.convert(1, "foobar", "m") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - self.assertEqual(result.parameter, "from_unit") - - def test_unknown_target_unit(self): - """Test that unknown target unit returns ConversionError.""" - result = self.convert(1, "m", "bazqux") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - self.assertEqual(result.parameter, "to_unit") - - def test_dimension_mismatch(self): - """Test that incompatible dimensions return ConversionError.""" - result = self.convert(1, "m", "s") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "dimension_mismatch") - - -class TestListUnitsTool(unittest.TestCase): - """Test the list_units tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import list_units, UnitInfo - cls.list_units = staticmethod(list_units) - cls.UnitInfo = UnitInfo - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_returns_list(self): - """Test that list_units returns a list.""" - result = self.list_units() - self.assertIsInstance(result, list) - self.assertGreater(len(result), 0) - - def test_returns_unit_info(self): - """Test that list items are UnitInfo objects.""" - result = self.list_units() - self.assertIsInstance(result[0], self.UnitInfo) - - def test_unit_info_fields(self): - """Test that UnitInfo has expected fields.""" - result = self.list_units() - unit = result[0] - self.assertIsNotNone(unit.name) - self.assertIsNotNone(unit.shorthand) - self.assertIsInstance(unit.aliases, list) - self.assertIsNotNone(unit.dimension) - self.assertIsInstance(unit.scalable, bool) - - def test_filter_by_dimension(self): - """Test filtering units by dimension.""" - result = self.list_units(dimension="length") - self.assertGreater(len(result), 0) - for unit in result: - self.assertEqual(unit.dimension, "length") - - def test_filter_excludes_other_dimensions(self): - """Test that filter excludes other dimensions.""" - length_units = self.list_units(dimension="length") - time_units = self.list_units(dimension="time") - - length_names = {u.name for u in length_units} - time_names = {u.name for u in time_units} - - self.assertTrue(length_names.isdisjoint(time_names)) - - def test_meter_is_scalable(self): - """Test that meter is marked as scalable.""" - result = self.list_units(dimension="length") - meter = next((u for u in result if u.name == "meter"), None) - self.assertIsNotNone(meter) - self.assertTrue(meter.scalable) - - def test_no_duplicates(self): - """Test that unit names are unique.""" - result = self.list_units() - names = [u.name for u in result] - self.assertEqual(len(names), len(set(names))) - - -class TestListScalesTool(unittest.TestCase): - """Test the list_scales tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import list_scales, ScaleInfo - cls.list_scales = staticmethod(list_scales) - cls.ScaleInfo = ScaleInfo - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_returns_list(self): - """Test that list_scales returns a list.""" - result = self.list_scales() - self.assertIsInstance(result, list) - self.assertGreater(len(result), 0) - - def test_returns_scale_info(self): - """Test that list items are ScaleInfo objects.""" - result = self.list_scales() - self.assertIsInstance(result[0], self.ScaleInfo) - - def test_scale_info_fields(self): - """Test that ScaleInfo has expected fields.""" - result = self.list_scales() - scale = result[0] - self.assertIsNotNone(scale.name) - self.assertIsNotNone(scale.prefix) - self.assertIsNotNone(scale.factor) - - def test_includes_kilo(self): - """Test that kilo is included.""" - result = self.list_scales() - kilo = next((s for s in result if s.name == "kilo"), None) - self.assertIsNotNone(kilo) - self.assertEqual(kilo.prefix, "k") - self.assertAlmostEqual(kilo.factor, 1000.0) - - def test_includes_milli(self): - """Test that milli is included.""" - result = self.list_scales() - milli = next((s for s in result if s.name == "milli"), None) - self.assertIsNotNone(milli) - self.assertEqual(milli.prefix, "m") - self.assertAlmostEqual(milli.factor, 0.001) - - def test_includes_binary_prefixes(self): - """Test that binary prefixes are included.""" - result = self.list_scales() - kibi = next((s for s in result if s.name == "kibi"), None) - self.assertIsNotNone(kibi) - self.assertEqual(kibi.prefix, "Ki") - self.assertAlmostEqual(kibi.factor, 1024.0) - - def test_excludes_identity_scale(self): - """Test that Scale.one is not included.""" - result = self.list_scales() - one = next((s for s in result if s.name == "one"), None) - self.assertIsNone(one) - - def test_matches_scale_enum(self): - """Test that all Scale enum members (except one) are represented.""" - result = self.list_scales() - result_names = {s.name for s in result} - - for scale in Scale: - if scale == Scale.one: - continue - self.assertIn(scale.name, result_names) - - -class TestCheckDimensionsTool(unittest.TestCase): - """Test the check_dimensions tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import check_dimensions, DimensionCheck - cls.check_dimensions = staticmethod(check_dimensions) - cls.DimensionCheck = DimensionCheck - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_compatible_same_unit(self): - """Test that same unit is compatible.""" - result = self.check_dimensions("m", "m") - self.assertTrue(result.compatible) - self.assertEqual(result.dimension_a, "length") - self.assertEqual(result.dimension_b, "length") - - def test_compatible_different_units_same_dimension(self): - """Test that different units of same dimension are compatible.""" - result = self.check_dimensions("m", "ft") - self.assertTrue(result.compatible) - - def test_compatible_scaled_units(self): - """Test that scaled units of same dimension are compatible.""" - result = self.check_dimensions("km", "mm") - self.assertTrue(result.compatible) - - def test_incompatible_different_dimensions(self): - """Test that different dimensions are incompatible.""" - result = self.check_dimensions("m", "s") - self.assertFalse(result.compatible) - self.assertEqual(result.dimension_a, "length") - self.assertEqual(result.dimension_b, "time") - - def test_returns_dimension_check(self): - """Test that check_dimensions returns DimensionCheck model.""" - result = self.check_dimensions("kg", "g") - self.assertIsInstance(result, self.DimensionCheck) - - -class TestListDimensionsTool(unittest.TestCase): - """Test the list_dimensions tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import list_dimensions - cls.list_dimensions = staticmethod(list_dimensions) - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_returns_list(self): - """Test that list_dimensions returns a list.""" - result = self.list_dimensions() - self.assertIsInstance(result, list) - self.assertGreater(len(result), 0) - - def test_includes_base_dimensions(self): - """Test that base dimensions are included.""" - result = self.list_dimensions() - self.assertIn("length", result) - self.assertIn("mass", result) - self.assertIn("time", result) - - def test_includes_derived_dimensions(self): - """Test that derived dimensions are included.""" - result = self.list_dimensions() - # Check for some common derived dimensions if they exist - # This depends on what's in the Dimension enum - self.assertIn("none", result) - - def test_matches_all_dimensions(self): - """Test that all standard dimensions are represented.""" - result = self.list_dimensions() - for dim in all_dimensions(): - self.assertIn(dim.name, result) - - def test_sorted(self): - """Test that dimensions are sorted alphabetically.""" - result = self.list_dimensions() - self.assertEqual(result, sorted(result)) - - -class TestConvertToolSuggestions(unittest.TestCase): - """Test suggestion features in the convert tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import convert - from ucon.mcp.suggestions import ConversionError - cls.convert = staticmethod(convert) - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_typo_single_match(self): - """Test that typo with single high-confidence match gets likely_fix.""" - result = self.convert(100, "meetr", "ft") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - self.assertEqual(result.parameter, "from_unit") - self.assertIsNotNone(result.likely_fix) - self.assertIn("meter", result.likely_fix) - - def test_bad_to_unit(self): - """Test that typo in to_unit position is detected.""" - result = self.convert(100, "meter", "feeet") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.parameter, "to_unit") - # Should suggest "foot" - self.assertTrue( - (result.likely_fix and "foot" in result.likely_fix) or - any("foot" in h for h in result.hints) - ) - - def test_unrecognizable_no_spurious_matches(self): - """Test that completely unknown unit doesn't produce spurious matches.""" - result = self.convert(100, "xyzzy", "kg") - self.assertIsInstance(result, self.ConversionError) - self.assertIsNone(result.likely_fix) - self.assertTrue(any("list_units" in h for h in result.hints)) - - def test_dimension_mismatch_readable(self): - """Test that dimension mismatch error uses readable names.""" - result = self.convert(100, "meter", "second") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "dimension_mismatch") - self.assertEqual(result.got, "length") - self.assertIn("length", result.error) - self.assertIn("time", result.error) - self.assertNotIn("Vector", result.error) - - def test_derived_dimension_readable(self): - """Test that derived dimension uses readable name in error.""" - result = self.convert(1, "m/s", "kg") - self.assertIsInstance(result, self.ConversionError) - self.assertIn("velocity", result.error) - self.assertNotIn("Vector", result.error) - - def test_unnamed_derived_dimension(self): - """Test that unnamed derived dimension doesn't show Vector.""" - result = self.convert(1, "m^3/s", "kg") - self.assertIsInstance(result, self.ConversionError) - # Should show readable format, not Vector(...) - self.assertNotIn("Vector", result.error) - # Should have some dimension info - self.assertTrue("length" in result.error or "derived(" in result.error) - - def test_pseudo_dimension_explains_isolation(self): - """Test that pseudo-dimension isolation is explained.""" - result = self.convert(1, "radian", "percent") - self.assertIsInstance(result, self.ConversionError) - # Pseudo-dimensions are semantically distinct, so this is a dimension mismatch - self.assertEqual(result.error_type, "dimension_mismatch") - # The "got" field is the source dimension, "expected" is the target - # But DimensionMismatch error now shows both in the same format - self.assertIn(result.got, ["angle", "ratio"]) - self.assertIn(result.expected, ["angle", "ratio"]) - - def test_compatible_units_in_hints(self): - """Test that dimension mismatch includes compatible units.""" - result = self.convert(100, "meter", "second") - self.assertIsInstance(result, self.ConversionError) - # Should suggest compatible length units - hints_str = str(result.hints) - self.assertTrue( - "ft" in hints_str or "in" in hints_str or - "foot" in hints_str or "inch" in hints_str - ) - - def test_no_vector_in_any_error(self): - """Test that no error response contains raw Vector representation.""" - cases = [ - ("m^3/s", "kg"), - ("kg*m/s^2", "A"), - ] - for from_u, to_u in cases: - result = self.convert(1, from_u, to_u) - if isinstance(result, self.ConversionError): - self.assertNotIn("Vector(", result.error) - for h in result.hints: - self.assertNotIn("Vector(", h) - - -class TestCheckDimensionsErrors(unittest.TestCase): - """Test error handling in the check_dimensions tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import check_dimensions - from ucon.mcp.suggestions import ConversionError - cls.check_dimensions = staticmethod(check_dimensions) - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_bad_unit_a(self): - """Test that bad unit_a returns ConversionError.""" - result = self.check_dimensions("meetr", "foot") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.parameter, "unit_a") - - def test_bad_unit_b(self): - """Test that bad unit_b returns ConversionError.""" - result = self.check_dimensions("meter", "fooot") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.parameter, "unit_b") - - -class TestListUnitsErrors(unittest.TestCase): - """Test error handling in the list_units tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import list_units - from ucon.mcp.suggestions import ConversionError - cls.list_units = staticmethod(list_units) - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_bad_dimension_filter(self): - """Test that bad dimension filter returns ConversionError.""" - result = self.list_units(dimension="lenth") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.parameter, "dimension") - # Should suggest "length" - self.assertTrue( - (result.likely_fix and "length" in result.likely_fix) or - any("length" in h for h in result.hints) - ) - - -class TestParseErrorHandling(unittest.TestCase): - """Test that malformed unit expressions return structured errors.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import convert, check_dimensions - from ucon.mcp.suggestions import ConversionError - cls.convert = staticmethod(convert) - cls.check_dimensions = staticmethod(check_dimensions) - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_unbalanced_parens_from_unit(self): - """Test that unbalanced parentheses in from_unit returns parse_error.""" - result = self.convert(1, "W/(m^2*K", "W/(m^2*K)") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "parse_error") - self.assertEqual(result.parameter, "from_unit") - self.assertIn("parse", result.error.lower()) - - def test_unbalanced_parens_to_unit(self): - """Test that unbalanced parentheses in to_unit returns parse_error.""" - result = self.convert(1, "W/(m^2*K)", "W/(m^2*K") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "parse_error") - self.assertEqual(result.parameter, "to_unit") - - def test_parse_error_in_check_dimensions(self): - """Test that parse errors work in check_dimensions too.""" - result = self.check_dimensions("m/s)", "m/s") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "parse_error") - self.assertEqual(result.parameter, "unit_a") - - def test_parse_error_hints_helpful(self): - """Test that parse error hints are helpful.""" - result = self.convert(1, "kg*(m/s^2", "N") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "parse_error") - # Should have hints about syntax - hints_str = str(result.hints) - self.assertTrue( - "parenthes" in hints_str.lower() or - "syntax" in hints_str.lower() or - "parse" in hints_str.lower() - ) - - -class TestCountDimensionMCP(unittest.TestCase): - """Test count dimension and each unit in MCP tools.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import ( - convert, list_units, list_dimensions, check_dimensions - ) - from ucon.mcp.suggestions import ConversionError - cls.convert = staticmethod(convert) - cls.list_units = staticmethod(list_units) - cls.list_dimensions = staticmethod(list_dimensions) - cls.check_dimensions = staticmethod(check_dimensions) - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_list_units_count_dimension(self): - """Test that list_units(dimension='count') returns each.""" - result = self.list_units(dimension="count") - names = [u.name for u in result] - self.assertIn("each", names) - - def test_list_dimensions_includes_count(self): - """Test that list_dimensions returns count.""" - result = self.list_dimensions() - self.assertIn("count", result) - - def test_convert_each_rejected_cross_dimension(self): - """Test that converting ea to rad is rejected (pseudo-dimension isolation).""" - result = self.convert(5, "ea", "rad") - self.assertIsInstance(result, self.ConversionError) - # Pseudo-dimensions are semantically distinct, so this is a dimension mismatch - self.assertEqual(result.error_type, "dimension_mismatch") - - def test_convert_each_to_percent_rejected(self): - """Test that converting ea to % is rejected (pseudo-dimension isolation).""" - result = self.convert(5, "ea", "%") - self.assertIsInstance(result, self.ConversionError) - # Pseudo-dimensions are semantically distinct, so this is a dimension mismatch - self.assertEqual(result.error_type, "dimension_mismatch") - - def test_check_dimensions_ea_vs_rad_incompatible(self): - """Test that ea and rad are incompatible.""" - result = self.check_dimensions("ea", "rad") - self.assertFalse(result.compatible) - self.assertEqual(result.dimension_a, "count") - self.assertEqual(result.dimension_b, "angle") - - def test_check_dimensions_mg_per_ea_vs_mg_compatible(self): - """Test that mg/ea and mg are compatible (count cancels dimensionally).""" - result = self.check_dimensions("mg/ea", "mg") - self.assertTrue(result.compatible) - self.assertEqual(result.dimension_a, "mass") - self.assertEqual(result.dimension_b, "mass") - - def test_each_fuzzy_recovery(self): - """Test that typo 'eech' suggests each.""" - result = self.convert(5, "eech", "kg") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - # Should suggest 'each' in likely_fix or hints - suggestions = (result.likely_fix or "") + str(result.hints) - self.assertTrue( - "each" in suggestions.lower() or "ea" in suggestions.lower(), - f"Expected 'each' or 'ea' in suggestions: {suggestions}" - ) - - -class TestComputeTool(unittest.TestCase): - """Test the compute tool for multi-step factor-label calculations.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import compute, ComputeResult, ComputeStep - from ucon.mcp.suggestions import ConversionError - cls.compute = staticmethod(compute) - cls.ComputeResult = ComputeResult - cls.ComputeStep = ComputeStep - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_simple_single_factor(self): - """Test simple single-factor conversion (km to m).""" - result = self.compute( - initial_value=5, - initial_unit="km", - factors=[ - {"value": 1000, "numerator": "m", "denominator": "km"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - self.assertAlmostEqual(result.quantity, 5000.0) - self.assertEqual(result.dimension, "length") - - def test_returns_steps(self): - """Test that compute returns step trace.""" - result = self.compute( - initial_value=10, - initial_unit="m", - factors=[ - {"value": 100, "numerator": "cm", "denominator": "m"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - self.assertIsInstance(result.steps, list) - self.assertEqual(len(result.steps), 2) # initial + 1 factor - self.assertIsInstance(result.steps[0], self.ComputeStep) - - def test_initial_step_recorded(self): - """Test that initial value is recorded as first step.""" - result = self.compute( - initial_value=100, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - self.assertIn("100", result.steps[0].factor) - self.assertIn("lb", result.steps[0].factor) - self.assertEqual(result.steps[0].dimension, "mass") - - def test_medical_dosage_calculation(self): - """Test medical dosing calculation: 154 lb patient, 15 mg/kg/day, 3 doses/day.""" - result = self.compute( - initial_value=154, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - {"value": 15, "numerator": "mg", "denominator": "kg*day"}, - {"value": 1, "numerator": "day", "denominator": "3 ea"}, # ea = each (dose) - ] - ) - self.assertIsInstance(result, self.ComputeResult) - # 154 lb × (1 kg / 2.205 lb) × (15 mg / kg·day) × (1 day / 3 ea) - # = 154 / 2.205 × 15 / 3 mg/ea - # ≈ 69.84 × 5 mg/ea ≈ 349.2 mg/ea - expected = 154 / 2.205 * 15 / 3 - self.assertAlmostEqual(result.quantity, expected, places=2) - # Should have mass/ea dimension → mass (count is dimensionless) - self.assertEqual(len(result.steps), 4) # initial + 3 factors - - def test_denominator_with_numeric_prefix(self): - """Test that denominators can have numeric prefixes (e.g., '2.205 lb').""" - result = self.compute( - initial_value=100, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 100 / 2.205 - self.assertAlmostEqual(result.quantity, expected, places=2) - - def test_multi_factor_unit_cancellation(self): - """Test that units cancel correctly across multiple factors.""" - # m/s * s/min * min/h → m/h - result = self.compute( - initial_value=1, - initial_unit="m/s", - factors=[ - {"value": 60, "numerator": "s", "denominator": "min"}, - {"value": 60, "numerator": "min", "denominator": "h"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - self.assertAlmostEqual(result.quantity, 3600.0) # 1 m/s = 3600 m/h - - # ------------------------------------------------------------------------- - # Chemical Engineering / Stoichiometry Tests - # ------------------------------------------------------------------------- - - def test_stoichiometry_molar_mass_conversion(self): - """Test molar mass calculation: grams to moles. - - Example: How many moles in 180 g of glucose (C6H12O6, MW = 180.16 g/mol)? - 180 g × (1 mol / 180.16 g) ≈ 0.999 mol - """ - result = self.compute( - initial_value=180, - initial_unit="g", - factors=[ - {"value": 1, "numerator": "mol", "denominator": "180.16 g"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 180 / 180.16 - self.assertAlmostEqual(result.quantity, expected, places=3) - - def test_stoichiometry_molarity_calculation(self): - """Test molarity calculation: moles per liter. - - Example: 0.5 mol NaCl in 250 mL water → molarity in mol/L - 0.5 mol × (1000 mL / 1 L) / 250 mL = 2.0 mol/L - """ - result = self.compute( - initial_value=0.5, - initial_unit="mol", - factors=[ - {"value": 1000, "numerator": "mL", "denominator": "L"}, - {"value": 1, "numerator": "1", "denominator": "250 mL"}, # dimensionless numerator - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 0.5 * 1000 / 250 - self.assertAlmostEqual(result.quantity, expected, places=3) - - def test_stoichiometry_reaction_yield(self): - """Test reaction stoichiometry with molar ratios. - - Example: 2 H2 + O2 → 2 H2O - Given 10 mol H2, how many grams of H2O produced? - 10 mol H2 × (2 mol H2O / 2 mol H2) × (18.015 g H2O / 1 mol H2O) = 180.15 g - - Using 'ea' for the stoichiometric ratio since mol cancels. - """ - result = self.compute( - initial_value=10, - initial_unit="mol", - factors=[ - {"value": 2, "numerator": "ea", "denominator": "2 ea"}, # 2:2 stoich ratio - {"value": 18.015, "numerator": "g", "denominator": "mol"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 10 * (2/2) * 18.015 - self.assertAlmostEqual(result.quantity, expected, places=2) - - # ------------------------------------------------------------------------- - # Multi-Factor Cancellation Tests (6+ factors) - # ------------------------------------------------------------------------- - - def test_six_factor_chain(self): - """Test 6-factor chain: complex unit conversion. - - Convert 1 mile/hour to cm/s: - 1 mi/h × (5280 ft/mi) × (12 in/ft) × (2.54 cm/in) × (1 h/60 min) × (1 min/60 s) - = 44.704 cm/s - """ - result = self.compute( - initial_value=1, - initial_unit="mi/h", - factors=[ - {"value": 5280, "numerator": "ft", "denominator": "mi"}, - {"value": 12, "numerator": "in", "denominator": "ft"}, - {"value": 2.54, "numerator": "cm", "denominator": "in"}, - {"value": 1, "numerator": "h", "denominator": "60 min"}, - {"value": 1, "numerator": "min", "denominator": "60 s"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 1 * 5280 * 12 * 2.54 / 60 / 60 - self.assertAlmostEqual(result.quantity, expected, places=2) - - def test_seven_factor_energy_chain(self): - """Test 7-factor chain: energy unit conversion with intermediates. - - Convert 1 kWh to BTU via joules and calories: - 1 kWh × (1000 W/kW) × (3600 s/h) × (1 J/W·s) × (1 cal/4.184 J) × (1 BTU/252 cal) - ≈ 3412 BTU - - Simplified version without composite unit parsing issues: - 1 kWh = 3.6e6 J, 1 BTU = 1055.06 J - So: value × (3.6e6 J / kWh) × (1 BTU / 1055.06 J) - """ - result = self.compute( - initial_value=1, - initial_unit="kWh", - factors=[ - {"value": 1000, "numerator": "W", "denominator": "kW"}, - {"value": 3600, "numerator": "s", "denominator": "h"}, - {"value": 1, "numerator": "J", "denominator": "W*s"}, - {"value": 1, "numerator": "cal", "denominator": "4.184 J"}, - {"value": 1, "numerator": "BTU", "denominator": "252 cal"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 1 * 1000 * 3600 / 4.184 / 252 - self.assertAlmostEqual(result.quantity, expected, places=0) # ~3412 BTU - - # ------------------------------------------------------------------------- - # Tests for 4+ Base Unit Cancellation - # ------------------------------------------------------------------------- - - def test_four_base_units_cancel(self): - """Test chain where 4 different base units cancel. - - Power density to force: W/m² × m × s / (m/s) = W·s/m = J/m = N - This involves: mass (kg), length (m), time (s), and their combinations. - - Simplified: 100 W × 1 s / 1 m = 100 J/m = 100 N - """ - result = self.compute( - initial_value=100, - initial_unit="W", - factors=[ - {"value": 1, "numerator": "s", "denominator": "1 ea"}, # × 1 s - {"value": 1, "numerator": "1", "denominator": "1 m"}, # / 1 m - ] - ) - self.assertIsInstance(result, self.ComputeResult) - # W·s/m = J/m = N, so 100 W·s/m = 100 N - self.assertAlmostEqual(result.quantity, 100.0, places=2) - - def test_pressure_volume_work_calculation(self): - """Test pressure × volume = energy with multiple unit conversions. - - 1 atm × 1 L = 101.325 J - Using: 1 atm × (101325 Pa/atm) × 1 L × (0.001 m³/L) = 101.325 Pa·m³ = 101.325 J - """ - result = self.compute( - initial_value=1, - initial_unit="atm", - factors=[ - {"value": 101325, "numerator": "Pa", "denominator": "atm"}, - {"value": 1, "numerator": "L", "denominator": "1 ea"}, # multiply by 1 L - {"value": 0.001, "numerator": "m^3", "denominator": "L"}, - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 1 * 101325 * 1 * 0.001 - self.assertAlmostEqual(result.quantity, expected, places=2) - - def test_flow_rate_mass_transfer(self): - """Test volumetric flow × density × time = mass. - - 10 L/min × 1.0 g/mL × 60 min = 600,000 g = 600 kg - - L/min × g/mL × min: - - L cancels with mL (factor 1000) - - min cancels - - Result: g - """ - result = self.compute( - initial_value=10, - initial_unit="L/min", - factors=[ - {"value": 1000, "numerator": "mL", "denominator": "L"}, # convert L to mL - {"value": 1.0, "numerator": "g", "denominator": "mL"}, # density - {"value": 60, "numerator": "min", "denominator": "1 ea"}, # time - ] - ) - self.assertIsInstance(result, self.ComputeResult) - expected = 10 * 1000 * 1.0 * 60 - self.assertAlmostEqual(result.quantity, expected, places=0) - - -class TestComputeToolErrors(unittest.TestCase): - """Test error handling in the compute tool.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import compute - from ucon.mcp.suggestions import ConversionError - cls.compute = staticmethod(compute) - cls.ConversionError = ConversionError - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_unknown_initial_unit(self): - """Test that unknown initial unit returns error.""" - result = self.compute( - initial_value=100, - initial_unit="foobar", - factors=[] - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - self.assertEqual(result.parameter, "initial_unit") - - def test_unknown_numerator_unit(self): - """Test that unknown numerator returns error with step.""" - result = self.compute( - initial_value=100, - initial_unit="m", - factors=[ - {"value": 1, "numerator": "foobar", "denominator": "m"}, - ] - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - self.assertEqual(result.parameter, "factors[0].numerator") - self.assertEqual(result.step, 0) - - def test_unknown_denominator_unit(self): - """Test that unknown denominator returns error with step.""" - result = self.compute( - initial_value=100, - initial_unit="m", - factors=[ - {"value": 1, "numerator": "km", "denominator": "bazqux"}, - ] - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - self.assertEqual(result.parameter, "factors[0].denominator") - self.assertEqual(result.step, 0) - - def test_error_localization_later_step(self): - """Test that errors in later steps report correct step number.""" - result = self.compute( - initial_value=100, - initial_unit="m", - factors=[ - {"value": 1000, "numerator": "mm", "denominator": "m"}, - {"value": 1, "numerator": "badunit", "denominator": "mm"}, - ] - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.step, 1) # Second factor (0-indexed) - self.assertIn("factors[1]", result.parameter) - - def test_missing_numerator(self): - """Test that missing numerator returns structured error.""" - result = self.compute( - initial_value=100, - initial_unit="m", - factors=[ - {"value": 1, "denominator": "m"}, # Missing numerator - ] - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "invalid_input") - self.assertIn("numerator", result.parameter) - self.assertEqual(result.step, 0) - - def test_missing_denominator(self): - """Test that missing denominator returns structured error.""" - result = self.compute( - initial_value=100, - initial_unit="m", - factors=[ - {"value": 1, "numerator": "km"}, # Missing denominator - ] - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "invalid_input") - self.assertIn("denominator", result.parameter) - - def test_parse_error_in_factor(self): - """Test that parse errors in factors are localized.""" - result = self.compute( - initial_value=100, - initial_unit="m", - factors=[ - {"value": 1, "numerator": "kg/(m", "denominator": "s"}, # Unbalanced - ] - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "parse_error") - self.assertEqual(result.step, 0) - - def test_empty_factors_returns_initial(self): - """Test that empty factors list returns initial value unchanged.""" - result = self.compute( - initial_value=100, - initial_unit="m", - factors=[] - ) - # Should return ComputeResult, not error - from ucon.mcp.server import ComputeResult - self.assertIsInstance(result, ComputeResult) - self.assertAlmostEqual(result.quantity, 100.0) - self.assertEqual(result.dimension, "length") - - -class TestSessionTools(unittest.TestCase): - """Test session management tools: define_unit, define_conversion, reset_session.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import ( - define_unit, define_conversion, reset_session, convert, - UnitDefinitionResult, ConversionDefinitionResult, SessionResult, - _reset_fallback_session, - ) - from ucon.mcp.suggestions import ConversionError - cls.define_unit = staticmethod(define_unit) - cls.define_conversion = staticmethod(define_conversion) - cls.reset_session = staticmethod(reset_session) - cls.convert = staticmethod(convert) - cls.UnitDefinitionResult = UnitDefinitionResult - cls.ConversionDefinitionResult = ConversionDefinitionResult - cls.SessionResult = SessionResult - cls.ConversionError = ConversionError - cls._reset_fallback_session = staticmethod(_reset_fallback_session) - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - # Reset session before each test - self._reset_fallback_session() - - def tearDown(self): - # Clean up session after each test - if not self.skip_tests: - self._reset_fallback_session() - - def test_define_unit_success(self): - """Test defining a custom unit successfully.""" - result = self.define_unit( - name="slug", - dimension="mass", - aliases=["slug"], - ) - self.assertIsInstance(result, self.UnitDefinitionResult) - self.assertTrue(result.success) - self.assertEqual(result.name, "slug") - self.assertEqual(result.dimension, "mass") - self.assertEqual(result.aliases, ["slug"]) - - def test_define_unit_invalid_dimension(self): - """Test that invalid dimension returns error with suggestions.""" - result = self.define_unit( - name="badunit", - dimension="nonexistent", - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.parameter, "dimension") - - def test_define_conversion_success(self): - """Test defining a conversion edge successfully.""" - # First define the unit - self.define_unit(name="slug", dimension="mass", aliases=["slug"]) - - # Then define the conversion - result = self.define_conversion(src="slug", dst="kg", factor=14.5939) - self.assertIsInstance(result, self.ConversionDefinitionResult) - self.assertTrue(result.success) - self.assertEqual(result.src, "slug") - self.assertEqual(result.dst, "kg") - self.assertAlmostEqual(result.factor, 14.5939) - - def test_define_conversion_unknown_unit(self): - """Test that conversion with unknown unit returns error.""" - result = self.define_conversion( - src="nonexistent", - dst="kg", - factor=1.0, - ) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - - def test_session_unit_usable_in_convert(self): - """Test that session-defined unit can be used in convert().""" - # Define unit and conversion - self.define_unit(name="slug", dimension="mass", aliases=["slug"]) - self.define_conversion(src="slug", dst="kg", factor=14.5939) - - # Use in convert - result = self.convert(1, "slug", "kg") - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 14.5939, places=3) - - def test_reset_session_clears_custom_units(self): - """Test that reset_session() clears custom units.""" - # Define unit and conversion - self.define_unit(name="slug", dimension="mass", aliases=["slug"]) - self.define_conversion(src="slug", dst="kg", factor=14.5939) - - # Verify it works - result = self.convert(1, "slug", "kg") - self.assertNotIsInstance(result, self.ConversionError) - - # Reset session - reset_result = self.reset_session() - self.assertIsInstance(reset_result, self.SessionResult) - self.assertTrue(reset_result.success) - - # Verify unit is no longer available - result = self.convert(1, "slug", "kg") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - - -class TestInlineParameters(unittest.TestCase): - """Test inline custom_units and custom_edges parameters.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import convert, compute, _reset_fallback_session - from ucon.mcp.suggestions import ConversionError - cls.convert = staticmethod(convert) - cls.compute = staticmethod(compute) - cls.ConversionError = ConversionError - cls._reset_fallback_session = staticmethod(_reset_fallback_session) - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - # Reset session to ensure clean state - self._reset_fallback_session() - - def tearDown(self): - if not self.skip_tests: - self._reset_fallback_session() - - def test_convert_with_inline_units(self): - """Test convert() with inline custom_units and custom_edges.""" - result = self.convert( - value=1, - from_unit="slug", - to_unit="kg", - custom_units=[ - {"name": "slug", "dimension": "mass", "aliases": ["slug"]}, - ], - custom_edges=[ - {"src": "slug", "dst": "kg", "factor": 14.5939}, - ], - ) - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 14.5939, places=3) - - def test_inline_does_not_modify_session(self): - """Test that inline definitions don't persist to session.""" - # Use inline definition - result = self.convert( - value=1, - from_unit="slug", - to_unit="kg", - custom_units=[ - {"name": "slug", "dimension": "mass", "aliases": ["slug"]}, - ], - custom_edges=[ - {"src": "slug", "dst": "kg", "factor": 14.5939}, - ], - ) - self.assertNotIsInstance(result, self.ConversionError) - - # Without inline, unit should not be available - result = self.convert(1, "slug", "kg") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - - def test_compute_with_inline_units(self): - """Test compute() with inline custom_units.""" - result = self.compute( - initial_value=1, - initial_unit="slug", - factors=[ - {"value": 14.5939, "numerator": "kg", "denominator": "slug"}, - ], - custom_units=[ - {"name": "slug", "dimension": "mass", "aliases": ["slug"]}, - ], - ) - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 14.5939, places=3) - - def test_invalid_inline_unit_dimension(self): - """Test that invalid dimension in inline unit returns error.""" - result = self.convert( - value=1, - from_unit="badunit", - to_unit="kg", - custom_units=[ - {"name": "badunit", "dimension": "nonexistent"}, - ], - ) - self.assertIsInstance(result, self.ConversionError) - self.assertIn("custom_units", result.parameter) - - def test_invalid_inline_edge_unit(self): - """Test that invalid unit in inline edge returns error.""" - result = self.convert( - value=1, - from_unit="kg", - to_unit="lb", - custom_edges=[ - {"src": "nonexistent", "dst": "kg", "factor": 1.0}, - ], - ) - self.assertIsInstance(result, self.ConversionError) - self.assertIn("custom_edges", result.parameter) - - def test_recovery_pattern(self): - """Test agent recovery pattern: unknown_unit error → retry with inline.""" - # First call fails - result = self.convert(1, "slug", "kg") - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "unknown_unit") - - # Retry with inline definitions succeeds - result = self.convert( - value=1, - from_unit="slug", - to_unit="kg", - custom_units=[ - {"name": "slug", "dimension": "mass", "aliases": ["slug"]}, - ], - custom_edges=[ - {"src": "slug", "dst": "kg", "factor": 14.5939}, - ], - ) - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 14.5939, places=3) - - -class TestGraphCaching(unittest.TestCase): - """Test that inline graph compilation is cached.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import ( - convert, _inline_graph_cache, _hash_definitions, _reset_fallback_session - ) - cls.convert = staticmethod(convert) - cls._inline_graph_cache = _inline_graph_cache - cls._hash_definitions = staticmethod(_hash_definitions) - cls._reset_fallback_session = staticmethod(_reset_fallback_session) - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - self._reset_fallback_session() - # Clear cache - self._inline_graph_cache.clear() - - def tearDown(self): - if not self.skip_tests: - self._reset_fallback_session() - self._inline_graph_cache.clear() - - def test_same_definitions_use_cache(self): - """Test that identical definitions use cached graph.""" - custom_units = [{"name": "slug", "dimension": "mass", "aliases": ["slug"]}] - custom_edges = [{"src": "slug", "dst": "kg", "factor": 14.5939}] - - # First call - self.convert(1, "slug", "kg", custom_units=custom_units, custom_edges=custom_edges) - cache_size_after_first = len(self._inline_graph_cache) - - # Second call with same definitions - self.convert(2, "slug", "kg", custom_units=custom_units, custom_edges=custom_edges) - cache_size_after_second = len(self._inline_graph_cache) - - # Cache should not grow (reusing same entry) - self.assertEqual(cache_size_after_first, cache_size_after_second) - self.assertEqual(cache_size_after_first, 1) - - def test_different_definitions_create_new_cache(self): - """Test that different definitions create new cache entries.""" - custom_units_a = [{"name": "unit_a", "dimension": "mass"}] - custom_units_b = [{"name": "unit_b", "dimension": "length"}] - - # First call - self.convert(1, "kg", "g", custom_units=custom_units_a) - cache_size_after_first = len(self._inline_graph_cache) - - # Second call with different definitions - self.convert(1, "m", "ft", custom_units=custom_units_b) - cache_size_after_second = len(self._inline_graph_cache) - - # Cache should grow - self.assertEqual(cache_size_after_second, cache_size_after_first + 1) - - def test_hash_stability(self): - """Test that hash is stable for same definitions in different order.""" - units_a = [{"name": "a", "dimension": "mass"}, {"name": "b", "dimension": "length"}] - units_b = [{"name": "b", "dimension": "length"}, {"name": "a", "dimension": "mass"}] - - hash_a = self._hash_definitions(units_a, None) - hash_b = self._hash_definitions(units_b, None) - - # Same content, different order → same hash (sorted internally) - self.assertEqual(hash_a, hash_b) - - -class TestSessionState(unittest.TestCase): - """Test SessionState protocol and DefaultSessionState implementation.""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.session import SessionState, DefaultSessionState - from ucon.core import Unit - from ucon.dimension import Dimension - cls.SessionState = SessionState - cls.DefaultSessionState = DefaultSessionState - cls.Unit = Unit - cls.Dimension = Dimension - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - - def test_default_session_state_implements_protocol(self): - """Test that DefaultSessionState implements SessionState protocol.""" - session = self.DefaultSessionState() - self.assertIsInstance(session, self.SessionState) - - def test_graph_persistence(self): - """Test that units registered in graph persist across get_graph() calls.""" - session = self.DefaultSessionState() - - graph1 = session.get_graph() - unit = self.Unit(name="test_unit", dimension=self.Dimension.mass) - graph1.register_unit(unit) - - graph2 = session.get_graph() - # Should be same instance - self.assertIs(graph1, graph2) - # Unit should be resolvable (returns tuple of (Unit, Scale)) - resolved = graph2.resolve_unit("test_unit") - self.assertIsNotNone(resolved) - resolved_unit, resolved_scale = resolved - self.assertEqual(resolved_unit.name, "test_unit") - - def test_constants_persistence(self): - """Test that constants dict persists across get_constants() calls.""" - from ucon.constants import Constant - - session = self.DefaultSessionState() - - constants1 = session.get_constants() - test_const = Constant( - symbol="test", - name="test constant", - value=42.0, - unit=units.meter, - uncertainty=None, - source="test", - category="session", - ) - constants1["test"] = test_const - - constants2 = session.get_constants() - # Should be same instance - self.assertIs(constants1, constants2) - # Constant should be present - self.assertIn("test", constants2) - self.assertEqual(constants2["test"].value, 42.0) - - def test_reset_clears_graph(self): - """Test that reset() clears custom units from graph.""" - session = self.DefaultSessionState() - - graph = session.get_graph() - unit = self.Unit(name="test_unit_reset", dimension=self.Dimension.mass) - graph.register_unit(unit) - - # Verify unit exists (returns tuple of (Unit, Scale)) - self.assertIsNotNone(graph.resolve_unit("test_unit_reset")) - - # Reset - session.reset() - - # Get new graph - new_graph = session.get_graph() - # Should be different instance - self.assertIsNot(graph, new_graph) - # Unit should not be resolvable - self.assertIsNone(new_graph.resolve_unit("test_unit_reset")) - - def test_reset_clears_constants(self): - """Test that reset() clears custom constants.""" - from ucon.constants import Constant - - session = self.DefaultSessionState() - - constants = session.get_constants() - test_const = Constant( - symbol="test", - name="test constant", - value=42.0, - unit=units.meter, - uncertainty=None, - source="test", - category="session", - ) - constants["test"] = test_const - - # Verify constant exists - self.assertIn("test", constants) - - # Reset - session.reset() - - # Get new constants - new_constants = session.get_constants() - # Should be different instance - self.assertIsNot(constants, new_constants) - # Constant should not be present - self.assertNotIn("test", new_constants) - - def test_custom_base_graph(self): - """Test that DefaultSessionState can use a custom base graph.""" - from ucon.graph import get_default_graph - - base = get_default_graph().copy() - custom_unit = self.Unit(name="base_unit_custom", dimension=self.Dimension.length) - base.register_unit(custom_unit) - - session = self.DefaultSessionState(base_graph=base) - graph = session.get_graph() - - # Custom unit should be available (returns tuple of (Unit, Scale)) - resolved = graph.resolve_unit("base_unit_custom") - self.assertIsNotNone(resolved) - - # After reset, custom unit should still be available (from base) - session.reset() - new_graph = session.get_graph() - resolved = new_graph.resolve_unit("base_unit_custom") - self.assertIsNotNone(resolved) - - -class TestConcurrencyFeedbackIssues(unittest.TestCase): - """Tests for issues identified in concurrency feedback (FEEDBACK_ucon-mcp-concurrency.md).""" - - @classmethod - def setUpClass(cls): - try: - from ucon.mcp.server import ( - define_unit, define_conversion, reset_session, convert, compute, - check_dimensions, list_units, - UnitDefinitionResult, ConversionDefinitionResult, - _reset_fallback_session, - ) - from ucon.mcp.suggestions import ConversionError - cls.define_unit = staticmethod(define_unit) - cls.define_conversion = staticmethod(define_conversion) - cls.reset_session = staticmethod(reset_session) - cls.convert = staticmethod(convert) - cls.compute = staticmethod(compute) - cls.check_dimensions = staticmethod(check_dimensions) - cls.list_units = staticmethod(list_units) - cls.UnitDefinitionResult = UnitDefinitionResult - cls.ConversionDefinitionResult = ConversionDefinitionResult - cls.ConversionError = ConversionError - cls._reset_fallback_session = staticmethod(_reset_fallback_session) - cls.skip_tests = False - except ImportError: - cls.skip_tests = True - - def setUp(self): - if self.skip_tests: - self.skipTest("mcp not installed") - self._reset_fallback_session() - - def tearDown(self): - if not self.skip_tests: - self._reset_fallback_session() - - # ------------------------------------------------------------------------- - # Issue 1: Unit Re-Registration Should Not Destroy Edges - # ------------------------------------------------------------------------- - - def test_issue1_reregistration_rejected(self): - """Issue 1: Re-registering a unit should be rejected to prevent edge loss.""" - # Define unit and conversion - result = self.define_unit(name="widget", dimension="count", aliases=["widget"]) - self.assertIsInstance(result, self.UnitDefinitionResult) - self.assertTrue(result.success) - - result = self.define_unit(name="gizmo", dimension="count", aliases=["gizmo"]) - self.assertTrue(result.success) - - result = self.define_conversion(src="widget", dst="gizmo", factor=3.5) - self.assertTrue(result.success) - - # Verify conversion works - result = self.convert(10, "widget", "gizmo") - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 35.0) - - # Attempt to re-register widget - should be rejected - result = self.define_unit(name="widget", dimension="count", aliases=["widget"]) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "duplicate_unit") - self.assertIn("widget", result.error) - - # Original conversion should still work - result = self.convert(10, "widget", "gizmo") - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 35.0) - - # ------------------------------------------------------------------------- - # Issue 2: Alias Collisions Should Be Rejected - # ------------------------------------------------------------------------- - - def test_issue2_alias_collision_same_dimension_rejected(self): - """Issue 2: Alias collision within same dimension should be rejected.""" - # Define first unit with alias "thing" - result = self.define_unit(name="alpha_thing", dimension="count", aliases=["thing"]) - self.assertTrue(result.success) - - # Attempt to define second unit with same alias - should be rejected - result = self.define_unit(name="beta_thing", dimension="count", aliases=["thing"]) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "alias_collision") - self.assertIn("thing", result.error) - self.assertIn("alpha_thing", result.error) - - def test_issue2_alias_collision_cross_dimension_rejected(self): - """Issue 2: Alias collision across dimensions should be rejected.""" - # Define first unit with alias "x" - result = self.define_unit(name="length_x", dimension="length", aliases=["x"]) - self.assertTrue(result.success) - - # Attempt to define second unit with same alias but different dimension - result = self.define_unit(name="mass_x", dimension="mass", aliases=["x"]) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "alias_collision") - self.assertIn("x", result.error) - - def test_issue2_alias_collision_with_builtin(self): - """Issue 2: Alias collision with built-in unit should be rejected.""" - # "m" is already used by meter - result = self.define_unit(name="custom_m", dimension="mass", aliases=["m"]) - self.assertIsInstance(result, self.ConversionError) - self.assertEqual(result.error_type, "alias_collision") - - # ------------------------------------------------------------------------- - # Issue 3: Session Units Should Be Visible to All Tools - # ------------------------------------------------------------------------- - - def test_issue3_check_dimensions_sees_session_units(self): - """Issue 3: check_dimensions should see session-defined units.""" - # Define a session unit - result = self.define_unit(name="mass_test", dimension="mass", aliases=["mass_test"]) - self.assertTrue(result.success) - - # check_dimensions should recognize it - result = self.check_dimensions("mass_test", "kg") - self.assertNotIsInstance(result, self.ConversionError) - self.assertTrue(result.compatible) - self.assertEqual(result.dimension_a, "mass") - self.assertEqual(result.dimension_b, "mass") - - def test_issue3_list_units_includes_session_units(self): - """Issue 3: list_units should include session-defined units.""" - # Define a session unit - result = self.define_unit(name="custom_mass_unit", dimension="mass", aliases=["cmu"]) - self.assertTrue(result.success) - - # list_units should include it - result = self.list_units(dimension="mass") - self.assertNotIsInstance(result, self.ConversionError) - - unit_names = [u.name for u in result] - self.assertIn("custom_mass_unit", unit_names) - - # ------------------------------------------------------------------------- - # Issue 4: compute Should See Session Units in Denominators - # ------------------------------------------------------------------------- - - def test_issue4_compute_sees_session_units_in_denominator(self): - """Issue 4: compute should resolve session units in denominator with numeric prefix.""" - # Define a session unit - result = self.define_unit(name="dose", dimension="count", aliases=["dose"]) - self.assertTrue(result.success) - - # compute should be able to use it in a denominator like "3 dose" - result = self.compute( - initial_value=154, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - {"value": 15, "numerator": "mg", "denominator": "kg*day"}, - {"value": 1, "numerator": "day", "denominator": "3 dose"}, - ] - ) - self.assertNotIsInstance(result, self.ConversionError, f"compute failed: {result}") - # 154 lb × (1 kg / 2.205 lb) × (15 mg / kg·day) × (1 day / 3 dose) - # ≈ 349.2 mg/dose - expected = 154 / 2.205 * 15 / 3 - self.assertAlmostEqual(result.quantity, expected, places=1) - - def test_issue4_compute_sees_session_units_in_numerator(self): - """Issue 4: compute should resolve session units in numerator too.""" - # Define session units - result = self.define_unit(name="widget", dimension="count", aliases=["widget"]) - self.assertTrue(result.success) - - # compute should be able to use session unit in numerator - result = self.compute( - initial_value=10, - initial_unit="kg", - factors=[ - {"value": 5, "numerator": "widget", "denominator": "kg"}, - ] - ) - self.assertNotIsInstance(result, self.ConversionError, f"compute failed: {result}") - self.assertAlmostEqual(result.quantity, 50.0) - - # ------------------------------------------------------------------------- - # Multi-hop Graph Traversal (confirmed working in feedback) - # ------------------------------------------------------------------------- - - def test_multi_hop_traversal(self): - """Confirm multi-hop traversal through session units works.""" - # widget → gizmo → doohickey - self.define_unit(name="widget", dimension="count", aliases=["widget"]) - self.define_unit(name="gizmo", dimension="count", aliases=["gizmo"]) - self.define_unit(name="doohickey", dimension="count", aliases=["doohickey"]) - - self.define_conversion(src="widget", dst="gizmo", factor=3.5) - self.define_conversion(src="gizmo", dst="doohickey", factor=2.0) - - # widget → doohickey = 3.5 × 2.0 = 7.0 - result = self.convert(10, "widget", "doohickey") - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 70.0) - - def test_inverse_traversal(self): - """Confirm inverse traversal works automatically.""" - self.define_unit(name="widget", dimension="count", aliases=["widget"]) - self.define_unit(name="gizmo", dimension="count", aliases=["gizmo"]) - self.define_conversion(src="widget", dst="gizmo", factor=3.5) - - # gizmo → widget (inverse) = 1/3.5 - result = self.convert(35, "gizmo", "widget") - self.assertNotIsInstance(result, self.ConversionError) - self.assertAlmostEqual(result.quantity, 10.0) diff --git a/tests/ucon/test_mcp_server.py b/tests/ucon/test_mcp_server.py deleted file mode 100644 index 843e19f..0000000 --- a/tests/ucon/test_mcp_server.py +++ /dev/null @@ -1,118 +0,0 @@ -# © 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 -# See the LICENSE file for details. - -""" -Tests for MCP server tools. - -Tests the convert, list_units, list_scales, check_dimensions, and -list_dimensions tools exposed via the MCP server. -""" - -import unittest - -try: - from ucon.mcp.server import convert, list_units, list_scales, check_dimensions, list_dimensions - HAS_MCP = True -except ImportError: - HAS_MCP = False - - -@unittest.skipUnless(HAS_MCP, "MCP not installed") -class TestConvert(unittest.TestCase): - """Test the convert tool.""" - - def test_basic_conversion(self): - result = convert(1000, "m", "km") - self.assertAlmostEqual(result.quantity, 1.0, places=9) - self.assertEqual(result.unit, "km") - - def test_returns_target_unit_string(self): - """Convert should return the target unit string as requested.""" - result = convert(100, "cm", "m") - self.assertEqual(result.unit, "m") - - def test_ratio_unit_preserved(self): - """Ratio units like mg/kg should preserve the unit string.""" - result = convert(0.1, "mg/kg", "µg/kg") - self.assertAlmostEqual(result.quantity, 100.0, places=6) - self.assertEqual(result.unit, "µg/kg") - - def test_medical_units(self): - """Medical unit aliases should work.""" - # mcg - result = convert(500, "mcg", "mg") - self.assertAlmostEqual(result.quantity, 0.5, places=9) - self.assertEqual(result.unit, "mg") - - # cc - result = convert(5, "cc", "mL") - self.assertAlmostEqual(result.quantity, 5.0, places=9) - self.assertEqual(result.unit, "mL") - - # min - result = convert(120, "mL/h", "mL/min") - self.assertAlmostEqual(result.quantity, 2.0, places=9) - self.assertEqual(result.unit, "mL/min") - - -@unittest.skipUnless(HAS_MCP, "MCP not installed") -class TestListUnits(unittest.TestCase): - """Test the list_units tool.""" - - def test_returns_units(self): - result = list_units() - self.assertIsInstance(result, list) - self.assertGreater(len(result), 0) - - def test_filter_by_dimension(self): - result = list_units(dimension="length") - self.assertGreater(len(result), 0) - for unit in result: - self.assertEqual(unit.dimension, "length") - - -@unittest.skipUnless(HAS_MCP, "MCP not installed") -class TestListScales(unittest.TestCase): - """Test the list_scales tool.""" - - def test_returns_scales(self): - result = list_scales() - self.assertIsInstance(result, list) - self.assertGreater(len(result), 0) - - def test_includes_kilo(self): - result = list_scales() - names = [s.name for s in result] - self.assertIn("kilo", names) - - -@unittest.skipUnless(HAS_MCP, "MCP not installed") -class TestCheckDimensions(unittest.TestCase): - """Test the check_dimensions tool.""" - - def test_compatible(self): - result = check_dimensions("m", "ft") - self.assertTrue(result.compatible) - self.assertEqual(result.dimension_a, "length") - self.assertEqual(result.dimension_b, "length") - - def test_incompatible(self): - result = check_dimensions("m", "s") - self.assertFalse(result.compatible) - - -@unittest.skipUnless(HAS_MCP, "MCP not installed") -class TestListDimensions(unittest.TestCase): - """Test the list_dimensions tool.""" - - def test_returns_dimensions(self): - result = list_dimensions() - self.assertIsInstance(result, list) - self.assertIn("length", result) - self.assertIn("mass", result) - self.assertIn("time", result) - - -if __name__ == '__main__': - unittest.main() diff --git a/ucon/__init__.py b/ucon/__init__.py index f708d80..82857d2 100644 --- a/ucon/__init__.py +++ b/ucon/__init__.py @@ -2,6 +2,10 @@ # Licensed under the Apache License, Version 2.0 # See the LICENSE file for details. +# Enable namespace package support for ucon-tools coexistence +from pkgutil import extend_path +__path__ = extend_path(__path__, __name__) + """ ucon ==== diff --git a/ucon/mcp/__init__.py b/ucon/mcp/__init__.py deleted file mode 100644 index e6f86a0..0000000 --- a/ucon/mcp/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# ucon MCP server -# -# Install: pip install ucon[mcp] -# Run: ucon-mcp - -from ucon.mcp.server import main -from ucon.mcp.session import DefaultSessionState, SessionState - -__all__ = ["main", "DefaultSessionState", "SessionState"] diff --git a/ucon/mcp/formulas.py b/ucon/mcp/formulas.py deleted file mode 100644 index 9989048..0000000 --- a/ucon/mcp/formulas.py +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 - -"""Formula registry for MCP-exposed domain calculations. - -Provides @register_formula decorator for registering dimensionally-typed -functions that can be discovered and called via MCP tools. -""" - -from dataclasses import dataclass -from typing import Callable - -from ucon.mcp.schema import extract_dimension_constraints - - -@dataclass(frozen=True) -class FormulaInfo: - """Metadata about a registered formula. - - Attributes - ---------- - name : str - Unique identifier for the formula. - description : str - Human-readable description of what the formula calculates. - parameters : dict[str, str | None] - Mapping of parameter name to dimension name (None if unconstrained). - fn : Callable - The actual function to call. - """ - name: str - description: str - parameters: dict[str, str | None] - fn: Callable - - -# Module-level registry -_FORMULA_REGISTRY: dict[str, FormulaInfo] = {} - - -def register_formula(name: str, *, description: str = "") -> Callable[[Callable], Callable]: - """Decorator to register a formula for MCP discovery. - - The decorated function should use Number[Dimension.X] type annotations - for parameters that have dimensional constraints. These constraints are - extracted and exposed in the formula's schema. - - Parameters - ---------- - name : str - Unique identifier for the formula (used in list_formulas, call_formula). - description : str, optional - Human-readable description of what the formula calculates. - - Returns - ------- - Callable - Decorator that registers the function and returns it unchanged. - - Examples - -------- - >>> from ucon import Number, Dimension, enforce_dimensions - >>> from ucon.mcp.formulas import register_formula - >>> - >>> @register_formula("bmi", description="Body Mass Index") - ... @enforce_dimensions - ... def bmi(mass: Number[Dimension.mass], height: Number[Dimension.length]) -> Number: - ... return mass / (height * height) - - Notes - ----- - - Use with @enforce_dimensions to get runtime validation - - The @register_formula decorator should be outermost (applied last) - - Formula names must be unique; re-registering raises ValueError - """ - def decorator(fn: Callable) -> Callable: - if name in _FORMULA_REGISTRY: - raise ValueError(f"Formula '{name}' is already registered") - - parameters = extract_dimension_constraints(fn) - - info = FormulaInfo( - name=name, - description=description, - parameters=parameters, - fn=fn, - ) - _FORMULA_REGISTRY[name] = info - - return fn - - return decorator - - -def list_formulas() -> list[FormulaInfo]: - """Return all registered formulas. - - Returns - ------- - list[FormulaInfo] - List of formula metadata, sorted by name. - """ - return sorted(_FORMULA_REGISTRY.values(), key=lambda f: f.name) - - -def get_formula(name: str) -> FormulaInfo | None: - """Look up a formula by name. - - Parameters - ---------- - name : str - The formula identifier. - - Returns - ------- - FormulaInfo | None - The formula info, or None if not found. - """ - return _FORMULA_REGISTRY.get(name) - - -def clear_formulas() -> None: - """Clear all registered formulas. Intended for testing.""" - _FORMULA_REGISTRY.clear() - - -__all__ = [ - 'FormulaInfo', - 'register_formula', - 'list_formulas', - 'get_formula', - 'clear_formulas', -] diff --git a/ucon/mcp/schema.py b/ucon/mcp/schema.py deleted file mode 100644 index 73c9e9a..0000000 --- a/ucon/mcp/schema.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 - -"""Schema introspection utilities for MCP tools. - -Extracts dimension constraints from @enforce_dimensions decorated functions -to expose in MCP tool schemas. -""" - -from typing import Callable, get_type_hints, get_origin, get_args, Annotated - -from ucon.core import DimConstraint - - -def extract_dimension_constraints(fn: Callable) -> dict[str, str | None]: - """Extract dimension constraints from a function's type annotations. - - Inspects parameters annotated with Number[Dimension.X] syntax and returns - a mapping of parameter name to dimension name. - - Parameters - ---------- - fn : Callable - A function, typically decorated with @enforce_dimensions. - - Returns - ------- - dict[str, str | None] - Mapping of parameter name to dimension name (or None if unconstrained). - - Examples - -------- - >>> from ucon import Number, Dimension, enforce_dimensions - >>> @enforce_dimensions - ... def speed(distance: Number[Dimension.length], time: Number[Dimension.time]) -> Number: - ... return distance / time - >>> extract_dimension_constraints(speed) - {'distance': 'length', 'time': 'time'} - """ - # Handle wrapped functions (from @enforce_dimensions or @functools.wraps) - target = getattr(fn, '__wrapped__', fn) - - try: - hints = get_type_hints(target, include_extras=True) - except Exception: - # If we can't get hints (e.g., forward refs), return empty - return {} - - constraints: dict[str, str | None] = {} - - for name, hint in hints.items(): - if name == "return": - continue - - # Check if this is an Annotated type with DimConstraint - if get_origin(hint) is Annotated: - args = get_args(hint) - for metadata in args[1:]: - if isinstance(metadata, DimConstraint): - constraints[name] = metadata.dimension.name - break - else: - # Annotated but no DimConstraint found - constraints[name] = None - else: - # Not annotated with dimension constraint - constraints[name] = None - - return constraints - - -__all__ = ['extract_dimension_constraints'] diff --git a/ucon/mcp/server.py b/ucon/mcp/server.py deleted file mode 100644 index 4b151b6..0000000 --- a/ucon/mcp/server.py +++ /dev/null @@ -1,1454 +0,0 @@ -# ucon MCP Server -# -# Provides unit conversion and dimensional analysis tools for AI agents. -# -# Usage: -# ucon-mcp # Run via entry point -# python -m ucon.mcp # Run as module - -import hashlib -import json -import re -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, AsyncIterator - -from mcp.server.fastmcp import FastMCP, Context -from pydantic import BaseModel - -from ucon import Dimension, get_default_graph -from ucon.dimension import all_dimensions -from ucon.core import Number, Scale, Unit, UnitProduct -from ucon.units import get_unit_by_name -from ucon.graph import ConversionGraph, DimensionMismatch, ConversionNotFound, using_graph -from ucon.maps import LinearMap -from ucon.mcp.formulas import list_formulas as _list_formulas, get_formula -from ucon.mcp.session import SessionState, DefaultSessionState -from ucon.mcp.suggestions import ( - ConversionError, - resolve_unit, - build_dimension_mismatch_error, - build_no_path_error, - build_unknown_dimension_error, -) -from ucon.packages import EdgeDef, PackageLoadError, UnitDef - - -@asynccontextmanager -async def lifespan(server: FastMCP) -> AsyncIterator[dict]: - """Server lifespan - creates session state that persists across tool calls.""" - yield {"session": DefaultSessionState()} - - -mcp = FastMCP("ucon", lifespan=lifespan) - - -# ----------------------------------------------------------------------------- -# Session Graph Management -# ----------------------------------------------------------------------------- - -# Cache for inline graph compilation (keyed by hash of definitions) -_inline_graph_cache: dict[str, ConversionGraph] = {} - - -def _get_session(ctx: Context | None) -> SessionState: - """Extract session state from context. - - Falls back to a default session for direct function calls (testing). - """ - if ctx is not None and hasattr(ctx, 'request_context'): - lifespan_ctx = ctx.request_context.lifespan_context - if lifespan_ctx and "session" in lifespan_ctx: - return lifespan_ctx["session"] - # Fallback for direct calls (testing without MCP context) - return _get_fallback_session() - - -# Fallback session for testing without MCP context -_fallback_session: DefaultSessionState | None = None - - -def _get_fallback_session() -> DefaultSessionState: - """Get or create fallback session for direct function calls.""" - global _fallback_session - if _fallback_session is None: - _fallback_session = DefaultSessionState() - return _fallback_session - - -def _reset_fallback_session() -> None: - """Reset the fallback session (for testing).""" - global _fallback_session - if _fallback_session is not None: - _fallback_session.reset() - - -def _resolve_constant(symbol: str, ctx: Context | None = None): - """Resolve a constant symbol from built-in or session constants.""" - from ucon.constants import get_constant_by_symbol - - # Try built-in first - const = get_constant_by_symbol(symbol) - if const is not None: - return const - - # Try session constants - session = _get_session(ctx) - return session.get_constants().get(symbol) - - -def _hash_definitions( - custom_units: list[dict] | None, - custom_edges: list[dict] | None, -) -> str: - """Compute a stable hash for inline definitions.""" - data = { - 'units': sorted([json.dumps(u, sort_keys=True) for u in (custom_units or [])]), - 'edges': sorted([json.dumps(e, sort_keys=True) for e in (custom_edges or [])]), - } - return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:16] - - -def _build_inline_graph( - custom_units: list[dict] | None, - custom_edges: list[dict] | None, - base_graph: ConversionGraph | None = None, -) -> tuple[ConversionGraph | None, ConversionError | None]: - """Build an ephemeral graph with inline definitions. - - Returns (graph, None) on success, (None, error) on failure. - Uses caching to avoid redundant compilation. - """ - if not custom_units and not custom_edges: - return None, None - - # Check cache - cache_key = _hash_definitions(custom_units, custom_edges) - if cache_key in _inline_graph_cache: - return _inline_graph_cache[cache_key], None - - # Build new graph from provided base (or default) - if base_graph is None: - base_graph = get_default_graph() - graph = base_graph.copy() - - # Register custom units first - for i, unit_dict in enumerate(custom_units or []): - try: - unit_def = UnitDef( - name=unit_dict.get('name', ''), - dimension=unit_dict.get('dimension', ''), - aliases=tuple(unit_dict.get('aliases', ())), - ) - unit = unit_def.materialize() - graph.register_unit(unit) - except PackageLoadError as e: - return None, ConversionError( - error=str(e), - error_type="invalid_input", - parameter=f"custom_units[{i}]", - hints=["Check dimension name is valid (use list_dimensions())"], - ) - except Exception as e: - return None, ConversionError( - error=f"Invalid unit definition: {e}", - error_type="invalid_input", - parameter=f"custom_units[{i}]", - hints=["Unit needs 'name' and 'dimension' fields"], - ) - - # Add custom edges - for i, edge_dict in enumerate(custom_edges or []): - try: - edge_def = EdgeDef( - src=edge_dict.get('src', ''), - dst=edge_dict.get('dst', ''), - factor=float(edge_dict.get('factor', 1.0)), - ) - edge_def.materialize(graph) - except PackageLoadError as e: - return None, ConversionError( - error=str(e), - error_type="invalid_input", - parameter=f"custom_edges[{i}]", - hints=["Check that src and dst units are defined"], - ) - except Exception as e: - return None, ConversionError( - error=f"Invalid edge definition: {e}", - error_type="invalid_input", - parameter=f"custom_edges[{i}]", - hints=["Edge needs 'src', 'dst', and 'factor' fields"], - ) - - # Cache the compiled graph - _inline_graph_cache[cache_key] = graph - return graph, None - - -# ----------------------------------------------------------------------------- -# Response Models -# ----------------------------------------------------------------------------- - - -class ConversionResult(BaseModel): - """Result of a unit conversion.""" - - quantity: float - unit: str | None - dimension: str - uncertainty: float | None = None - - -class UnitInfo(BaseModel): - """Information about an available unit.""" - - name: str - shorthand: str - aliases: list[str] - dimension: str - scalable: bool - - -class ScaleInfo(BaseModel): - """Information about a scale prefix.""" - - name: str - prefix: str - factor: float - - -class DimensionCheck(BaseModel): - """Result of a dimensional compatibility check.""" - - compatible: bool - dimension_a: str - dimension_b: str - - -class ComputeStep(BaseModel): - """A single step in a compute chain.""" - - factor: str - dimension: str - unit: str - - -class ComputeResult(BaseModel): - """Result of a multi-step factor-label computation.""" - - quantity: float - unit: str - dimension: str - steps: list[ComputeStep] - - -class SessionResult(BaseModel): - """Result of a session management operation.""" - - success: bool - message: str - - -class UnitDefinitionResult(BaseModel): - """Result of defining a custom unit.""" - - success: bool - name: str - dimension: str - aliases: list[str] - message: str - - -class ConversionDefinitionResult(BaseModel): - """Result of defining a custom conversion.""" - - success: bool - src: str - dst: str - factor: float - message: str - - -class FormulaInfoResponse(BaseModel): - """Metadata about a registered formula.""" - - name: str - description: str - parameters: dict[str, str | None] - - -class FormulaResult(BaseModel): - """Result of calling a registered formula.""" - - formula: str - quantity: float - unit: str | None - dimension: str - uncertainty: float | None = None - - -class FormulaError(BaseModel): - """Error from calling a formula.""" - - error: str - error_type: str # "unknown_formula", "invalid_parameter", "dimension_mismatch", "missing_parameter" - formula: str | None = None - parameter: str | None = None - expected: str | None = None - got: str | None = None - hints: list[str] = [] - - -class ConstantInfo(BaseModel): - """Information about a physical constant.""" - - symbol: str - name: str - value: float - unit: str | None - dimension: str - uncertainty: float | None - is_exact: bool - source: str - category: str # "exact", "derived", "measured", "session" - - -class ConstantDefinitionResult(BaseModel): - """Result of defining a custom constant.""" - - success: bool - symbol: str - name: str - unit: str - uncertainty: float | None - message: str - - -class ConstantError(BaseModel): - """Error from constant operations.""" - - error: str - error_type: str # "duplicate_symbol", "invalid_unit", "invalid_value", "unknown_constant", "invalid_input" - parameter: str | None = None - hints: list[str] = [] - - -def _constant_to_info(const, category: str | None = None) -> ConstantInfo: - """Convert a Constant to ConstantInfo for MCP response.""" - unit_str = None - if hasattr(const.unit, 'shorthand'): - unit_str = const.unit.shorthand - elif hasattr(const.unit, 'name'): - unit_str = const.unit.name - else: - unit_str = str(const.unit) - - dim_name = const.dimension.name if hasattr(const.dimension, 'name') else str(const.dimension) - - return ConstantInfo( - symbol=const.symbol, - name=const.name, - value=const.value, - unit=unit_str, - dimension=dim_name, - uncertainty=const.uncertainty, - is_exact=const.is_exact, - source=const.source, - category=category or getattr(const, 'category', 'measured'), - ) - - -# ----------------------------------------------------------------------------- -# Tools -# ----------------------------------------------------------------------------- - - -@mcp.tool() -def convert( - value: float, - from_unit: str, - to_unit: str, - custom_units: list[dict] | None = None, - custom_edges: list[dict] | None = None, - ctx: Context | None = None, -) -> ConversionResult | ConversionError: - """ - Convert a numeric value from one unit to another. - - Units can be specified as: - - Base units: "meter", "m", "second", "s", "gram", "g" - - Scaled units: "km", "mL", "kg", "MHz" (use list_scales for prefixes) - - Composite units: "m/s", "kg*m/s^2", "N*m" - - Exponents: "m^2", "s^-1" (ASCII) or "m²", "s⁻¹" (Unicode) - - For custom/domain-specific units, you can either: - 1. Use define_unit() and define_conversion() to register them for the session - 2. Pass them inline via custom_units and custom_edges parameters - - Args: - value: The numeric quantity to convert. - from_unit: Source unit string. - to_unit: Target unit string. - custom_units: Optional list of inline unit definitions for this call only. - Each dict should have: {"name": str, "dimension": str, "aliases": [str]} - custom_edges: Optional list of inline conversion edges for this call only. - Each dict should have: {"src": str, "dst": str, "factor": float} - - Returns: - ConversionResult with converted quantity, unit, and dimension. - ConversionError if the conversion fails, with suggestions for correction. - - Example with inline definitions: - convert(1, "slug", "kg", - custom_units=[{"name": "slug", "dimension": "mass", "aliases": ["slug"]}], - custom_edges=[{"src": "slug", "dst": "kg", "factor": 14.5939}]) - """ - session = _get_session(ctx) - session_graph = session.get_graph() - - # Build inline graph if custom definitions provided - inline_graph, err = _build_inline_graph(custom_units, custom_edges, session_graph) - if err: - return err - - # Use inline graph or session graph - graph = inline_graph or session_graph - - # Perform resolution and conversion within graph context - with using_graph(graph): - # 1. Parse source unit - src, err = resolve_unit(from_unit, parameter="from_unit") - if err: - return err - - # 2. Parse target unit - dst, err = resolve_unit(to_unit, parameter="to_unit") - if err: - return err - - # 3. Perform conversion - try: - num = Number(quantity=value, unit=src) - result = num.to(dst, graph=graph) - except DimensionMismatch: - return build_dimension_mismatch_error(from_unit, to_unit, src, dst) - except ConversionNotFound as e: - return build_no_path_error(from_unit, to_unit, src, dst, e) - - # Use the target unit string as output (what the user asked for). - # This handles cases like mg/kg → µg/kg where internal representation - # may lose unit info due to dimension cancellation. - unit_str = to_unit - dim_name = dst.dimension.name if hasattr(dst, 'dimension') else "none" - - return ConversionResult( - quantity=result.quantity, - unit=unit_str, - dimension=dim_name, - uncertainty=result.uncertainty, - ) - - -@mcp.tool() -def list_units( - dimension: str | None = None, - ctx: Context | None = None, -) -> list[UnitInfo] | ConversionError: - """ - List available units, optionally filtered by dimension. - - Returns base units only. Use scale prefixes (from list_scales) to form - scaled variants. For example, "meter" with prefix "k" becomes "km". - - Includes both built-in units and session-defined units (from define_unit). - - Args: - dimension: Optional filter by dimension name (e.g., "length", "mass", "time"). - Use list_dimensions() to see available dimensions. - - Returns: - List of UnitInfo objects describing available units. - ConversionError if the dimension filter is invalid. - """ - import ucon.units as units_module - - # Validate dimension filter if provided - if dimension: - known_dimensions = [d.name for d in all_dimensions()] - if dimension not in known_dimensions: - return build_unknown_dimension_error(dimension) - - # Units that accept SI scale prefixes - SCALABLE_UNITS = { - "meter", "gram", "second", "ampere", "kelvin", "mole", "candela", - "hertz", "newton", "pascal", "joule", "watt", "coulomb", "volt", - "farad", "ohm", "siemens", "weber", "tesla", "henry", "lumen", - "lux", "becquerel", "gray", "sievert", "katal", - "liter", "byte", - } - - result = [] - seen_names = set() - - # Built-in units from ucon.units module - for name in dir(units_module): - obj = getattr(units_module, name) - if isinstance(obj, Unit) and obj.name and obj.name not in seen_names: - seen_names.add(obj.name) - - if dimension and obj.dimension.name != dimension: - continue - - result.append( - UnitInfo( - name=obj.name, - shorthand=obj.shorthand, - aliases=list(obj.aliases) if obj.aliases else [], - dimension=obj.dimension.name, - scalable=obj.name in SCALABLE_UNITS, - ) - ) - - # Session-defined units from graph registry - session = _get_session(ctx) - graph = session.get_graph() - - # Get unique units from graph's case-sensitive registry - # (registry maps names/aliases to units, so we need unique values) - session_units = set(graph._name_registry_cs.values()) - for unit in session_units: - if unit.name and unit.name not in seen_names: - seen_names.add(unit.name) - - if dimension and unit.dimension.name != dimension: - continue - - result.append( - UnitInfo( - name=unit.name, - shorthand=unit.shorthand or unit.name, - aliases=list(unit.aliases) if unit.aliases else [], - dimension=unit.dimension.name, - scalable=False, # Session units are not scalable by default - ) - ) - - return sorted(result, key=lambda u: (u.dimension, u.name)) - - -@mcp.tool() -def list_scales() -> list[ScaleInfo]: - """ - List available scale prefixes for units. - - These prefixes can be combined with scalable units (see list_units). - For example, prefix "k" (kilo) with unit "m" (meter) forms "km". - - Includes both SI decimal prefixes (kilo, mega, milli, micro, etc.) - and binary prefixes (kibi, mebi, gibi) for information units. - - Note on bytes: - - SI prefixes: kB = 1000 B, MB = 1,000,000 B (decimal) - - Binary prefixes: KiB = 1024 B, MiB = 1,048,576 B (powers of 2) - - Returns: - List of ScaleInfo objects with name, prefix symbol, and numeric factor. - """ - result = [] - for scale in Scale: - if scale == Scale.one: - continue # Skip the identity scale - result.append( - ScaleInfo( - name=scale.name, - prefix=scale.shorthand, - factor=scale.descriptor.evaluated, - ) - ) - return sorted(result, key=lambda s: -s.factor) - - -@mcp.tool() -def check_dimensions( - unit_a: str, - unit_b: str, - ctx: Context | None = None, -) -> DimensionCheck | ConversionError: - """ - Check if two units have compatible dimensions. - - Units with the same dimension can be converted between each other. - Units with different dimensions cannot be added or directly compared. - - Args: - unit_a: First unit string. - unit_b: Second unit string. - - Returns: - DimensionCheck indicating compatibility and the dimension of each unit. - ConversionError if a unit string cannot be parsed. - """ - session = _get_session(ctx) - graph = session.get_graph() - - # Resolve units within session graph context - with using_graph(graph): - a, err = resolve_unit(unit_a, parameter="unit_a") - if err: - return err - - b, err = resolve_unit(unit_b, parameter="unit_b") - if err: - return err - - dim_a = a.dimension if isinstance(a, Unit) else a.dimension - dim_b = b.dimension if isinstance(b, Unit) else b.dimension - - return DimensionCheck( - compatible=(dim_a == dim_b), - dimension_a=dim_a.name, - dimension_b=dim_b.name, - ) - - -@mcp.tool() -def compute( - initial_value: float, - initial_unit: str, - factors: list[dict], - custom_units: list[dict] | None = None, - custom_edges: list[dict] | None = None, - ctx: Context | None = None, -) -> ComputeResult | ConversionError: - """ - Perform multi-step factor-label calculations with dimensional tracking. - - This tool processes a chain of conversion factors, validating dimensional - consistency at each step. It's designed for dosage calculations, stoichiometry, - and other multi-step unit conversions. - - Each factor is applied as: result = result × (value × numerator / denominator) - - For custom/domain-specific units, you can either: - 1. Use define_unit() and define_conversion() to register them for the session - 2. Pass them inline via custom_units and custom_edges parameters - - Args: - initial_value: Starting numeric quantity. - initial_unit: Starting unit string. - factors: List of conversion factors. Each factor is a dict with: - - value: Numeric coefficient (multiplied into numerator) - - numerator: Numerator unit string (e.g., "kg", "mg") - - denominator: Denominator unit string, optionally with numeric prefix - (e.g., "lb", "2.205 lb", "kg*day") - custom_units: Optional list of inline unit definitions for this call only. - Each dict should have: {"name": str, "dimension": str, "aliases": [str]} - custom_edges: Optional list of inline conversion edges for this call only. - Each dict should have: {"src": str, "dst": str, "factor": float} - - Returns: - ComputeResult with final quantity, unit, dimension, and step-by-step trace. - ConversionError with step localization if any factor fails. - - Example: - # Convert 154 lb to mg/dose for a drug with 15 mg/kg/day dosing, 3 doses/day - compute( - initial_value=154, - initial_unit="lb", - factors=[ - {"value": 1, "numerator": "kg", "denominator": "2.205 lb"}, - {"value": 15, "numerator": "mg", "denominator": "kg*day"}, - {"value": 1, "numerator": "day", "denominator": "3 dose"}, - ] - ) - """ - session = _get_session(ctx) - session_graph = session.get_graph() - - # Build inline graph if custom definitions provided - inline_graph, err = _build_inline_graph(custom_units, custom_edges, session_graph) - if err: - return err - - # Use inline graph or session graph - graph = inline_graph or session_graph - - # All unit resolution within graph context - with using_graph(graph): - # Parse initial unit - initial_parsed, err = resolve_unit(initial_unit, parameter="initial_unit") - if err: - return err - - # Track numeric value separately from unit accumulator - # The flat accumulator keys by (unit.name, dimension, scale) so that - # mg and kg remain separate entries (different scales, shouldn't cancel) - running_value = float(initial_value) - accum: dict[tuple, tuple] = {} - _accumulate_factors(accum, initial_parsed, +1.0) - - steps: list[ComputeStep] = [] - - # Record initial state - running_unit = _build_product_from_accum(accum) - initial_dim = initial_parsed.dimension.name - initial_unit_str = _format_unit_output(running_unit) - steps.append(ComputeStep( - factor=f"{initial_value} {initial_unit}", - dimension=initial_dim, - unit=initial_unit_str, - )) - - # Process each factor - for i, factor in enumerate(factors): - step_num = i + 1 # 1-indexed for user-facing errors - - # Validate factor structure - if not isinstance(factor, dict): - return ConversionError( - error=f"Factor at step {step_num} must be a dict", - error_type="invalid_input", - parameter=f"factors[{i}]", - step=i, - hints=["Each factor should be: {\"value\": float, \"numerator\": str, \"denominator\": str}"], - ) - - value = factor.get("value", 1.0) - numerator = factor.get("numerator") - denominator = factor.get("denominator") - - if numerator is None: - return ConversionError( - error=f"Factor at step {step_num} missing 'numerator' field", - error_type="invalid_input", - parameter=f"factors[{i}].numerator", - step=i, - hints=["Each factor needs a numerator unit string"], - ) - - if denominator is None: - return ConversionError( - error=f"Factor at step {step_num} missing 'denominator' field", - error_type="invalid_input", - parameter=f"factors[{i}].denominator", - step=i, - hints=["Each factor needs a denominator unit string"], - ) - - # Parse numerator unit - num_unit, err = resolve_unit(numerator, parameter=f"factors[{i}].numerator", step=i) - if err: - return err - - # Parse denominator - may have numeric prefix (e.g., "2.205 lb") - denom_value = 1.0 - denom_unit_str = denominator.strip() - - # Try to extract leading number from denominator - match = re.match(r'^([0-9]*\.?[0-9]+)\s*(.+)$', denom_unit_str) - if match: - denom_value = float(match.group(1)) - denom_unit_str = match.group(2).strip() - - denom_unit, err = resolve_unit(denom_unit_str, parameter=f"factors[{i}].denominator", step=i) - if err: - return err - - # Apply factor: multiply by (value * num_unit) / (denom_value * denom_unit) - try: - # Compute numeric factor: value / denom_value - numeric_factor = value / denom_value - running_value *= numeric_factor - - # Accumulate numerator factors at +1, denominator factors at -1 - _accumulate_factors(accum, num_unit, +1.0) - _accumulate_factors(accum, denom_unit, -1.0) - - # Build current unit product for step recording - running_unit = _build_product_from_accum(accum) - - except Exception as e: - return ConversionError( - error=f"Error applying factor at step {step_num}: {str(e)}", - error_type="computation_error", - parameter=f"factors[{i}]", - step=i, - hints=["Check that units are compatible for this operation"], - ) - - # Record step - result_dim = running_unit.dimension.name if running_unit else "none" - result_unit_str = _format_unit_output(running_unit) - - # Format factor description - if denom_value != 1.0: - factor_desc = f"× ({value} {numerator} / {denom_value} {denom_unit_str})" - else: - factor_desc = f"× ({value} {numerator} / {denom_unit_str})" - - steps.append(ComputeStep( - factor=factor_desc, - dimension=result_dim, - unit=result_unit_str, - )) - - # Build final result - running_unit = _build_product_from_accum(accum) - final_dim = running_unit.dimension.name if running_unit else "none" - final_unit_str = _format_unit_output(running_unit) - - return ComputeResult( - quantity=running_value, - unit=final_unit_str, - dimension=final_dim, - steps=steps, - ) - - -def _format_unit_output(unit) -> str: - """Format a unit or unit product for output display.""" - if unit is None: - return "1" - elif isinstance(unit, Unit): - return unit.shorthand or unit.name - elif isinstance(unit, UnitProduct): - return unit.shorthand or "1" - else: - return str(unit) - - -def _accumulate_factors( - accum: dict[tuple, tuple], - product: Unit | UnitProduct, - sign: float, -) -> None: - """Add all UnitFactors from a parsed unit into the accumulator. - - The accumulator is keyed by (unit.name, dimension, scale) so that - same-unit-different-scale entries (mg vs kg) don't cancel. - - Args: - accum: The accumulator dict mapping key → (UnitFactor, exponent). - product: A Unit or UnitProduct to accumulate. - sign: +1.0 for numerator factors, -1.0 for denominator factors. - """ - from ucon.core import UnitFactor - - if isinstance(product, Unit): - product = UnitProduct.from_unit(product) - - for uf, exp in product.factors.items(): - key = (uf.unit.name, uf.unit.dimension, uf.scale) - if key in accum: - existing_uf, existing_exp = accum[key] - accum[key] = (existing_uf, existing_exp + exp * sign) - else: - accum[key] = (uf, exp * sign) - - -def _build_product_from_accum( - accum: dict[tuple, tuple], -) -> UnitProduct: - """Build a UnitProduct from surviving non-zero accumulator entries.""" - surviving = {} - for key, (uf, exp) in accum.items(): - if abs(exp) > 1e-12: - surviving[uf] = exp - if not surviving: - return UnitProduct({}) - return UnitProduct(surviving) - - -@mcp.tool() -def list_dimensions() -> list[str]: - """ - List available physical dimensions. - - Dimensions represent fundamental physical quantities (length, mass, time, etc.) - and derived quantities (velocity, force, energy, etc.). - - Use these dimension names to filter list_units(). - - Returns: - List of dimension names. - """ - return sorted([d.name for d in all_dimensions()]) - - -# ----------------------------------------------------------------------------- -# Session Management Tools -# ----------------------------------------------------------------------------- - - -@mcp.tool() -def list_constants( - category: str | None = None, - ctx: Context | None = None, -) -> list[ConstantInfo] | ConstantError: - """ - List available physical constants, optionally filtered by category. - - Categories: - - "exact": SI defining constants (c, h, e, k_B, N_A, K_cd, ΔνCs) - - "derived": Constants derived from exact values (ℏ, R, σ) - - "measured": Constants with experimental uncertainty (G, α, m_e, etc.) - - "session": User-defined constants for this session - - "all" or None: Return all constants - - Args: - category: Optional category filter. - - Returns: - List of ConstantInfo objects describing available constants. - ConstantError if the category is invalid. - """ - valid_categories = {"exact", "derived", "measured", "session", "all", None} - if category not in valid_categories: - return ConstantError( - error=f"Unknown category: '{category}'", - error_type="invalid_input", - parameter="category", - hints=["Valid categories: exact, derived, measured, session, all"], - ) - - from ucon.constants import all_constants - - session = _get_session(ctx) - result = [] - - # Built-in constants - if category != "session": - for const in all_constants(): - if category and category != "all" and const.category != category: - continue - result.append(_constant_to_info(const)) - - # Session constants - if category in (None, "all", "session"): - for const in session.get_constants().values(): - result.append(_constant_to_info(const, category="session")) - - return sorted(result, key=lambda c: (c.category, c.symbol)) - - -@mcp.tool() -def define_constant( - symbol: str, - name: str, - value: float, - unit: str, - uncertainty: float | None = None, - source: str = "user-defined", - ctx: Context | None = None, -) -> ConstantDefinitionResult | ConstantError: - """ - Define a custom constant for the current session. - - The constant will be available for use in compute() and other tools - until reset_session() is called. - - Args: - symbol: Short symbol for the constant (e.g., "vₛ", "Eg"). - name: Full descriptive name. - value: Numeric value in the given units. - unit: Unit string (e.g., "m/s", "J", "kg*m/s^2"). - uncertainty: Standard uncertainty (None for exact constants). - source: Data source reference. - - Returns: - ConstantDefinitionResult on success. - ConstantError if the symbol is already defined or unit is invalid. - - Example: - define_constant( - symbol="vₛ", - name="speed of sound in dry air at 20°C", - value=343, - unit="m/s" - ) - """ - import math - from ucon.constants import Constant, get_constant_by_symbol - - # Check for duplicate symbol in built-in constants - existing = get_constant_by_symbol(symbol) - if existing is not None: - return ConstantError( - error=f"Symbol '{symbol}' is already defined as '{existing.name}'", - error_type="duplicate_symbol", - parameter="symbol", - hints=["Use a different symbol or use the built-in constant"], - ) - - # Check for duplicate in session constants - session = _get_session(ctx) - session_constants = session.get_constants() - if symbol in session_constants: - return ConstantError( - error=f"Symbol '{symbol}' is already defined in this session", - error_type="duplicate_symbol", - parameter="symbol", - hints=["Use reset_session() to clear session constants"], - ) - - # Validate value - if not isinstance(value, (int, float)) or math.isnan(value) or math.isinf(value): - return ConstantError( - error=f"Invalid value: {value}", - error_type="invalid_value", - parameter="value", - hints=["Value must be a finite number"], - ) - - # Parse unit - parsed_unit, err = resolve_unit(unit, parameter="unit") - if err: - return ConstantError( - error=f"Invalid unit: '{unit}'", - error_type="invalid_unit", - parameter="unit", - hints=err.hints if hasattr(err, 'hints') else [], - ) - - # Validate uncertainty - if uncertainty is not None: - if not isinstance(uncertainty, (int, float)) or uncertainty < 0: - return ConstantError( - error=f"Invalid uncertainty: {uncertainty}", - error_type="invalid_value", - parameter="uncertainty", - hints=["Uncertainty must be a non-negative number or None"], - ) - - # Create constant - const = Constant( - symbol=symbol, - name=name, - value=float(value), - unit=parsed_unit, - uncertainty=uncertainty, - source=source, - category="session", - ) - - # Register in session - session_constants[symbol] = const - - return ConstantDefinitionResult( - success=True, - symbol=symbol, - name=name, - unit=unit, - uncertainty=uncertainty, - message=f"Constant '{symbol}' registered for session.", - ) - - -@mcp.tool() -def define_unit( - name: str, - dimension: str, - aliases: list[str] | None = None, - ctx: Context | None = None, -) -> UnitDefinitionResult | ConversionError: - """ - Define a custom unit for the current session. - - The unit will be available for all subsequent convert() and compute() calls - until reset_session() is called. Use this to extend ucon with domain-specific - units (e.g., "slug" for aerospace, "mmHg" for medical). - - After defining a unit, use define_conversion() to add conversion edges - to existing units. - - Args: - name: Canonical name of the unit (e.g., "slug", "nautical_mile"). - dimension: Dimension name (e.g., "mass", "length"). Use list_dimensions() - to see available dimensions. - aliases: Optional list of shorthand symbols (e.g., ["slug"] or ["nmi", "NM"]). - - Returns: - UnitDefinitionResult confirming the unit was registered. - ConversionError if the dimension is invalid. - - Example: - define_unit(name="slug", dimension="mass", aliases=["slug"]) - """ - aliases = aliases or [] - - # Get session graph first for validation - session = _get_session(ctx) - graph = session.get_graph() - - # Validate dimension - known_dimensions = [d.name for d in all_dimensions()] - if dimension not in known_dimensions: - return build_unknown_dimension_error(dimension) - - # Check for duplicate unit name (Issue 1: re-registration destroys edges) - existing = graph.resolve_unit(name) - if existing is not None: - existing_unit, _ = existing - return ConversionError( - error=f"Unit '{name}' is already defined (dimension: {existing_unit.dimension.name})", - error_type="duplicate_unit", - parameter="name", - hints=[ - "Use a different name for the new unit", - "Use reset_session() to clear all custom definitions", - ], - ) - - # Check for alias collisions (Issue 2: alias collisions silently accepted) - for alias in aliases: - existing = graph.resolve_unit(alias) - if existing is not None: - existing_unit, _ = existing - return ConversionError( - error=f"Alias '{alias}' is already claimed by unit '{existing_unit.name}' (dimension: {existing_unit.dimension.name})", - error_type="alias_collision", - parameter="aliases", - hints=[ - f"Remove '{alias}' from aliases or use a different alias", - f"The existing unit '{existing_unit.name}' already uses this alias", - ], - ) - - # Create unit definition and materialize - try: - unit_def = UnitDef( - name=name, - dimension=dimension, - aliases=tuple(aliases), - ) - unit = unit_def.materialize() - except PackageLoadError as e: - return ConversionError( - error=str(e), - error_type="invalid_input", - parameter="dimension", - hints=["Use list_dimensions() to see available dimensions"], - ) - - # Register in session graph - graph.register_unit(unit) - - return UnitDefinitionResult( - success=True, - name=name, - dimension=dimension, - aliases=aliases, - message=f"Unit '{name}' registered for session. Use define_conversion() to add conversion edges.", - ) - - -@mcp.tool() -def define_conversion( - src: str, - dst: str, - factor: float, - ctx: Context | None = None, -) -> ConversionDefinitionResult | ConversionError: - """ - Define a conversion edge between two units for the current session. - - The conversion factor specifies: dst_value = src_value × factor - - Both src and dst must be resolvable units - either standard ucon units - or custom units previously defined via define_unit(). - - Args: - src: Source unit name or alias (e.g., "slug"). - dst: Destination unit name or alias (e.g., "kg"). - factor: Conversion multiplier (e.g., 14.5939 for slug → kg). - - Returns: - ConversionDefinitionResult confirming the edge was added. - ConversionError if either unit cannot be resolved. - - Example: - define_conversion(src="slug", dst="kg", factor=14.5939) - """ - session = _get_session(ctx) - graph = session.get_graph() - - # Create edge definition and materialize - try: - edge_def = EdgeDef(src=src, dst=dst, factor=factor) - edge_def.materialize(graph) - except PackageLoadError as e: - return ConversionError( - error=str(e), - error_type="unknown_unit", - parameter="src" if src in str(e) else "dst", - hints=[ - "Make sure both units are defined (use define_unit() for custom units)", - "Use list_units() to see available standard units", - ], - ) - - return ConversionDefinitionResult( - success=True, - src=src, - dst=dst, - factor=factor, - message=f"Conversion edge '{src}' → '{dst}' (factor={factor}) added to session.", - ) - - -@mcp.tool() -def reset_session(ctx: Context | None = None) -> SessionResult: - """ - Reset the session, clearing all custom units, conversions, and constants. - - After reset, the session starts fresh with only the standard ucon units - and built-in physical constants. Any units defined via define_unit(), - edges from define_conversion(), and constants from define_constant() - will be removed. - - Returns: - SessionResult confirming the reset. - """ - session = _get_session(ctx) - session.reset() - return SessionResult( - success=True, - message="Session reset. All custom units, conversions, and constants cleared.", - ) - - -# ----------------------------------------------------------------------------- -# Formula Discovery Tools -# ----------------------------------------------------------------------------- - - -@mcp.tool() -def list_formulas() -> list[FormulaInfoResponse]: - """ - List all registered domain formulas with their dimensional constraints. - - Returns formulas that have been registered via @register_formula decorator. - Each formula includes parameter names and their expected dimensions, enabling - pre-call validation of inputs. - - Use this to discover available calculations and understand their dimensional - requirements before calling. - - Returns: - List of formula metadata including name, description, and parameter dimensions. - - Example response: - [ - { - "name": "fib4", - "description": "FIB-4 liver fibrosis score", - "parameters": { - "age": "time", - "ast": "frequency", - "alt": "frequency", - "platelets": null - } - } - ] - """ - formulas = _list_formulas() - return [ - FormulaInfoResponse( - name=f.name, - description=f.description, - parameters=f.parameters, - ) - for f in formulas - ] - - -def _number_dimension(num: Number) -> Dimension: - """Extract dimension from a Number.""" - if num.unit is None: - return Dimension.dimensionless - if isinstance(num.unit, Unit): - return num.unit.dimension - if isinstance(num.unit, UnitProduct): - return num.unit.dimension - return Dimension.dimensionless - - -@mcp.tool() -def call_formula( - name: str, - parameters: dict[str, dict], -) -> FormulaResult | FormulaError: - """ - Call a registered formula with the given parameters. - - Use list_formulas() first to discover available formulas and their - expected parameter dimensions. - - Args: - name: The formula name (from list_formulas). - parameters: Dict mapping parameter names to values. Each value should be: - - {"value": 5.0, "unit": "kg"} for dimensioned quantities - - {"value": 5.0} for dimensionless quantities - - Returns: - FormulaResult on success, FormulaError on failure. - - Example: - call_formula( - name="bmi", - parameters={ - "mass": {"value": 70, "unit": "kg"}, - "height": {"value": 1.75, "unit": "m"} - } - ) - # Returns: {"formula": "bmi", "quantity": 22.86, "unit": "kg/m²", ...} - """ - # Look up the formula - info = get_formula(name) - if info is None: - available = [f.name for f in _list_formulas()] - hints = [] - if available: - hints.append(f"Available formulas: {', '.join(available)}") - else: - hints.append("No formulas registered. Formulas must be registered via @register_formula.") - return FormulaError( - error=f"Unknown formula: '{name}'", - error_type="unknown_formula", - formula=name, - hints=hints, - ) - - # Build the arguments - kwargs = {} - for param_name, expected_dim in info.parameters.items(): - if param_name not in parameters: - return FormulaError( - error=f"Missing required parameter: '{param_name}'", - error_type="missing_parameter", - formula=name, - parameter=param_name, - expected=expected_dim, - hints=[f"Parameter '{param_name}' expects dimension: {expected_dim or 'any'}"], - ) - - param_value = parameters[param_name] - - # Parse the parameter value - if not isinstance(param_value, dict): - return FormulaError( - error=f"Invalid parameter format for '{param_name}': expected dict with 'value' key", - error_type="invalid_parameter", - formula=name, - parameter=param_name, - hints=["Parameters should be: {\"value\": 5.0, \"unit\": \"kg\"} or {\"value\": 5.0}"], - ) - - if "value" not in param_value: - return FormulaError( - error=f"Parameter '{param_name}' missing 'value' key", - error_type="invalid_parameter", - formula=name, - parameter=param_name, - hints=["Parameters should be: {\"value\": 5.0, \"unit\": \"kg\"} or {\"value\": 5.0}"], - ) - - value = param_value["value"] - unit_str = param_value.get("unit") - - if unit_str: - # Parse the unit - try: - unit = get_unit_by_name(unit_str) - except Exception as e: - return FormulaError( - error=f"Unknown unit '{unit_str}' for parameter '{param_name}'", - error_type="invalid_parameter", - formula=name, - parameter=param_name, - got=unit_str, - hints=[str(e)], - ) - kwargs[param_name] = Number(value, unit) - else: - # Dimensionless - kwargs[param_name] = Number(value) - - # Call the formula - try: - result = info.fn(**kwargs) - except ValueError as e: - # Dimension mismatch from @enforce_dimensions - error_msg = str(e) - # Parse out parameter name if possible - param = None - expected = None - got = None - if ": expected dimension" in error_msg: - parts = error_msg.split(":") - if len(parts) >= 2: - param = parts[0].strip() - # Try to extract expected/got - match = re.search(r"expected dimension '(\w+)', got '(\w+)'", error_msg) - if match: - expected = match.group(1) - got = match.group(2) - return FormulaError( - error=error_msg, - error_type="dimension_mismatch", - formula=name, - parameter=param, - expected=expected, - got=got, - hints=[f"Check that '{param}' has the correct dimension" if param else "Check parameter dimensions"], - ) - except TypeError as e: - return FormulaError( - error=str(e), - error_type="invalid_parameter", - formula=name, - hints=["Check parameter types match formula signature"], - ) - except Exception as e: - return FormulaError( - error=f"Formula execution failed: {e}", - error_type="execution_error", - formula=name, - hints=[], - ) - - # Format the result - if isinstance(result, Number): - unit_str = None - if result.unit is not None: - unit_str = result.unit.shorthand - dim = _number_dimension(result) - return FormulaResult( - formula=name, - quantity=result.quantity, - unit=unit_str, - dimension=dim.name, - uncertainty=result.uncertainty, - ) - else: - # Non-Number result (shouldn't happen for well-typed formulas) - return FormulaResult( - formula=name, - quantity=float(result), - unit=None, - dimension="unknown", - ) - - -# ----------------------------------------------------------------------------- -# Entry Point -# ----------------------------------------------------------------------------- - - -def main(): - """Run the ucon MCP server.""" - mcp.run() - - -if __name__ == "__main__": - main() diff --git a/ucon/mcp/session.py b/ucon/mcp/session.py deleted file mode 100644 index 6a8a3ab..0000000 --- a/ucon/mcp/session.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 - -""" -ucon.mcp.session -================ - -Injectable session state for MCP tools. - -Provides session persistence across tool calls using FastMCP's lifespan context. -ContextVar-based isolation doesn't work for MCP because each tool call runs in -a separate async task. The lifespan context persists for the server's lifetime -and is accessible to all tools via Context injection. -""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Protocol, runtime_checkable - -if TYPE_CHECKING: - from ucon.constants import Constant - from ucon.graph import ConversionGraph - - -@runtime_checkable -class SessionState(Protocol): - """Protocol for injectable MCP session state. - - Allows dependency injection of session management for testing - and custom implementations. - - Concurrency Model - ----------------- - MCP protocol is request-response: client waits for each response before - the next request. Tool calls are sequential by protocol design, so no - locks are needed. Session state modifications are single-writer. - """ - - def get_graph(self) -> "ConversionGraph": - """Get the session's conversion graph.""" - ... - - def get_constants(self) -> dict[str, "Constant"]: - """Get the session's custom constants.""" - ... - - def reset(self) -> None: - """Reset session to default state.""" - ... - - -class DefaultSessionState: - """Default session state implementation. - - Maintains a single conversion graph and constants dict - for the lifetime of the MCP server session. - - Parameters - ---------- - base_graph : ConversionGraph | None - Optional base graph to copy from. If None, uses get_default_graph(). - - Examples - -------- - >>> session = DefaultSessionState() - >>> graph = session.get_graph() - >>> graph.register_unit(custom_unit) - >>> # Unit persists across subsequent get_graph() calls - >>> graph2 = session.get_graph() - >>> assert graph is graph2 # Same instance - """ - - def __init__(self, base_graph: "ConversionGraph | None" = None): - self._base_graph = base_graph - self._graph: "ConversionGraph | None" = None - self._constants: dict[str, "Constant"] = {} - - def get_graph(self) -> "ConversionGraph": - """Get or create the session graph. - - Returns a copy of the base graph on first access, then reuses - the session graph for subsequent calls. - """ - if self._graph is None: - from ucon.graph import get_default_graph - base = self._base_graph or get_default_graph() - self._graph = base.copy() - return self._graph - - def get_constants(self) -> dict[str, "Constant"]: - """Get the session's custom constants dictionary.""" - return self._constants - - def reset(self) -> None: - """Reset session to default state. - - Creates a fresh copy of the base graph and clears custom constants. - """ - from ucon.graph import get_default_graph - base = self._base_graph or get_default_graph() - self._graph = base.copy() - self._constants = {} diff --git a/ucon/mcp/suggestions.py b/ucon/mcp/suggestions.py deleted file mode 100644 index 3a236b9..0000000 --- a/ucon/mcp/suggestions.py +++ /dev/null @@ -1,542 +0,0 @@ -# © 2026 The Radiativity Company -# Licensed under the Apache License, Version 2.0 -# See the LICENSE file for details. - -""" -ucon.mcp.suggestions -==================== - -Suggestion logic for MCP error responses, optimized for AI agent self-correction. - -This module provides helper functions for building structured error responses -with high-confidence fixes (likely_fix) and lower-confidence hints. The split -enables agents to distinguish mechanical corrections from exploratory suggestions. -""" -from __future__ import annotations - -from difflib import SequenceMatcher, get_close_matches -from typing import TYPE_CHECKING - -from pydantic import BaseModel - -from ucon import get_unit_by_name -from ucon.parsing import ParseError -from ucon.units import UnknownUnitError - -if TYPE_CHECKING: - from ucon.core import Dimension, Unit, UnitProduct - - -class ConversionError(BaseModel): - """Structured error response optimized for agent self-correction. - - Attributes - ---------- - error : str - Human-readable description of what went wrong. - error_type : str - One of: "unknown_unit", "dimension_mismatch", "no_conversion_path", - "parse_error". - parameter : str | None - Which input caused the error (e.g., "from_unit", "to_unit", "unit_a"). - step : int | None - For multi-step chains (compute tool), the 0-indexed step where the - error occurred. None for single conversions. - got : str | None - What the agent provided (dimension or unit name). - expected : str | None - What was expected (dimension name). - likely_fix : str | None - High-confidence mechanical fix. When present, the agent should apply - it without additional reasoning. - hints : list[str] - Lower-confidence exploratory suggestions. The agent should reason - about these or escalate to the user. - """ - - error: str - error_type: str - parameter: str | None = None - step: int | None = None - got: str | None = None - expected: str | None = None - likely_fix: str | None = None - hints: list[str] = [] - - -# ----------------------------------------------------------------------------- -# Fuzzy Matching -# ----------------------------------------------------------------------------- - - -def _get_fuzzy_corpus() -> list[str]: - """All registry keys suitable for fuzzy matching. - - Returns the case-insensitive keys from _UNIT_REGISTRY. - Excludes generated scaled variants (km, MHz, etc.) to prevent dilution. - """ - from ucon.units import _UNIT_REGISTRY - - return list(_UNIT_REGISTRY.keys()) - - -def _similarity(a: str, b: str) -> float: - """SequenceMatcher ratio between two strings.""" - return SequenceMatcher(None, a, b).ratio() - - -def _suggest_units(bad_name: str) -> tuple[str | None, list[str]]: - """Fuzzy match a bad unit name against the registry. - - Parameters - ---------- - bad_name : str - The unrecognized unit string. - - Returns - ------- - tuple[str | None, list[str]] - (likely_fix, similar_names) where likely_fix is set when - the top match scores >= 0.7 and is significantly better than - alternatives. Ambiguous matches go to hints only. - """ - from ucon.units import _UNIT_REGISTRY - - corpus = _get_fuzzy_corpus() - matches = get_close_matches(bad_name.lower(), corpus, n=3, cutoff=0.6) - - if not matches: - return None, [] - - # Check top match score - top_score = _similarity(bad_name.lower(), matches[0]) - - # High-confidence top match (>= 0.7) with clear gap to second match → likely_fix - if top_score >= 0.7: - # If there's a second match, check if top is clearly better - if len(matches) >= 2: - second_score = _similarity(bad_name.lower(), matches[1]) - # Gap of 0.1 means top match is clearly the intended unit - if top_score - second_score >= 0.1: - unit = _UNIT_REGISTRY[matches[0]] - # Include other matches as hints - other_formatted = [ - _format_unit_with_aliases(_UNIT_REGISTRY[m]) - for m in matches[1:] - ] - return _format_unit_with_aliases(unit), other_formatted - else: - # Single match at >= 0.7 → definitely likely_fix - unit = _UNIT_REGISTRY[matches[0]] - return _format_unit_with_aliases(unit), [] - - # Multiple matches with similar scores or lower confidence → hints only - formatted = [_format_unit_with_aliases(_UNIT_REGISTRY[m]) for m in matches] - return None, formatted - - -def _format_unit_with_aliases(unit: 'Unit') -> str: - """Format a unit with its shorthand for display: 'meter (m)'.""" - if unit.shorthand and unit.shorthand != unit.name: - return f"{unit.name} ({unit.shorthand})" - return unit.name - - -# ----------------------------------------------------------------------------- -# Compatible Units -# ----------------------------------------------------------------------------- - - -def _get_compatible_units(dimension: 'Dimension', limit: int = 5) -> list[str]: - """Find units with conversion paths for a given dimension. - - Walks ConversionGraph._unit_edges rather than filtering by dimension alone, - so only units with actual conversion paths are returned. - - Parameters - ---------- - dimension : Dimension - The dimension to find compatible units for. - limit : int - Maximum number of units to return. - - Returns - ------- - list[str] - Unit shorthands or names with conversion paths. - """ - from ucon.graph import get_default_graph - - graph = get_default_graph() - if dimension not in graph._unit_edges: - return [] - - units = [] - for unit in graph._unit_edges[dimension]: - # Skip RebasedUnit instances - if hasattr(unit, 'original'): - continue - label = unit.shorthand or unit.name - if label and label not in units: - units.append(label) - if len(units) >= limit: - break - return units - - -def _get_dimension_name(unit) -> str: - """Get readable dimension name from a Unit or UnitProduct. - - Named dimensions return their name (e.g., 'velocity'). - Unnamed derived dimensions return 'derived(length^3/time)'. - Never returns 'Vector(...)'. - - Parameters - ---------- - unit : Unit or UnitProduct - The unit to get the dimension name for. - - Returns - ------- - str - Human-readable dimension name. - """ - dim = unit.dimension - return dim.name - - -# ----------------------------------------------------------------------------- -# Unit Resolution Helper -# ----------------------------------------------------------------------------- - - -def resolve_unit( - name: str, - parameter: str, - step: int | None = None, -): - """Try to parse a unit string, returning a structured error on failure. - - This helper reduces try/except boilerplate in MCP tools. - - Parameters - ---------- - name : str - The unit string to parse. - parameter : str - Which parameter this is (e.g., "from_unit", "to_unit"). - step : int | None - For multi-step chains, the 0-indexed step. - - Returns - ------- - tuple[Unit | UnitProduct, None] | tuple[None, ConversionError] - On success: (parsed_unit, None) - On failure: (None, ConversionError) - """ - try: - return get_unit_by_name(name), None - except UnknownUnitError: - return None, build_unknown_unit_error(name, parameter=parameter, step=step) - except ParseError as e: - return None, build_parse_error(name, str(e), parameter=parameter, step=step) - - -# ----------------------------------------------------------------------------- -# Error Builders -# ----------------------------------------------------------------------------- - - -def build_unknown_unit_error( - bad_name: str, - parameter: str, - step: int | None = None, -) -> ConversionError: - """Build a ConversionError for an unknown unit. - - Parameters - ---------- - bad_name : str - The unrecognized unit string. - parameter : str - Which parameter was bad (e.g., "from_unit", "to_unit"). - step : int | None - For multi-step chains, the 0-indexed step where the error occurred. - - Returns - ------- - ConversionError - Structured error with fuzzy match suggestions. - """ - likely_fix, similar = _suggest_units(bad_name) - - hints = [] - - # If we have a likely_fix but also other similar units, mention them - if likely_fix and similar: - hints.append(f"Other similar units: {', '.join(similar)}") - elif similar: - # No likely_fix, just hints - hints.append(f"Similar units: {', '.join(similar)}") - elif not likely_fix: - # No matches at all - hints.append("No similar units found") - hints.append("Use list_units() to see all available units") - - # Generic hints always included - hints.append("For scaled variants, combine with a prefix: km, mm, µm (see list_scales)") - hints.append("For composite units: m/s, kg*m/s^2") - - # Limit to 3 hints - hints = hints[:3] - - return ConversionError( - error=f"Unknown unit: '{bad_name}'", - error_type="unknown_unit", - parameter=parameter, - step=step, - likely_fix=likely_fix, - hints=hints, - ) - - -def build_dimension_mismatch_error( - from_unit_str: str, - to_unit_str: str, - src_unit, - dst_unit, - step: int | None = None, -) -> ConversionError: - """Build a ConversionError for a dimension mismatch. - - Parameters - ---------- - from_unit_str : str - The source unit string as provided by the user. - to_unit_str : str - The target unit string as provided by the user. - src_unit : Unit or UnitProduct - The parsed source unit. - dst_unit : Unit or UnitProduct - The parsed target unit. - step : int | None - For multi-step chains, the 0-indexed step where the error occurred. - - Returns - ------- - ConversionError - Structured error with dimension info and compatible units. - """ - src_dim_name = _get_dimension_name(src_unit) - dst_dim_name = _get_dimension_name(dst_unit) - - # Build hints - hints = [f"{from_unit_str} is {src_dim_name}; {to_unit_str} is {dst_dim_name}"] - - # Get compatible units for source dimension - compatible = _get_compatible_units(src_unit.dimension) - if compatible: - hints.append(f"Compatible {src_dim_name} units: {', '.join(compatible)}") - else: - hints.append("These are fundamentally different physical quantities") - - hints.append("Use check_dimensions() to verify compatibility before converting") - - # Limit to 3 hints - hints = hints[:3] - - return ConversionError( - error=f"Cannot convert '{from_unit_str}' to '{to_unit_str}': " - f"{src_dim_name} is not compatible with {dst_dim_name}", - error_type="dimension_mismatch", - parameter="to_unit", - step=step, - got=src_dim_name, - expected=src_dim_name, # Expected same dimension as source - hints=hints, - ) - - -def build_no_path_error( - from_unit_str: str, - to_unit_str: str, - src_unit, - dst_unit, - exception: Exception, - step: int | None = None, -) -> ConversionError: - """Build a ConversionError for a missing conversion path. - - Parameters - ---------- - from_unit_str : str - The source unit string as provided by the user. - to_unit_str : str - The target unit string as provided by the user. - src_unit : Unit or UnitProduct - The parsed source unit. - dst_unit : Unit or UnitProduct - The parsed target unit. - exception : Exception - The ConversionNotFound exception. - step : int | None - For multi-step chains, the 0-indexed step where the error occurred. - - Returns - ------- - ConversionError - Structured error explaining why conversion is impossible. - """ - src_dim = src_unit.dimension - dst_dim = dst_unit.dimension - src_dim_name = _get_dimension_name(src_unit) - dst_dim_name = _get_dimension_name(dst_unit) - - hints = [f"{from_unit_str} is {src_dim_name}; {to_unit_str} is {dst_dim_name}"] - - # Check if this is pseudo-dimension isolation - exc_msg = str(exception) - is_pseudo_isolation = "pseudo-dimension" in exc_msg.lower() - - if is_pseudo_isolation or (src_dim != dst_dim and src_dim.vector == dst_dim.vector): - # Pseudo-dimension isolation (angle, ratio, solid_angle share zero vector) - hints.append( - f"{src_dim_name} and {dst_dim_name} are isolated pseudo-dimensions — " - "they cannot interconvert" - ) - - # Provide workaround hints based on the specific pseudo-dimensions - if src_dim_name == "angle": - hints.append("To express an angle as a fraction, compute angle/(2π) explicitly") - other_units = _get_compatible_units(src_dim) - if other_units: - hints.append(f"Other angle units: {', '.join(other_units)}") - elif src_dim_name == "ratio": - other_units = _get_compatible_units(src_dim) - if other_units: - hints.append(f"Other ratio units: {', '.join(other_units)}") - elif src_dim_name == "solid_angle": - other_units = _get_compatible_units(src_dim) - if other_units: - hints.append(f"Other solid angle units: {', '.join(other_units)}") - - elif src_dim == dst_dim: - # Same dimension but missing edge — suggest intermediate - hints.append( - "Both units are in the same dimension, but no direct conversion edge exists" - ) - hints.append("Convert via an intermediate: try converting to a base unit first") - compatible = _get_compatible_units(src_dim) - if compatible: - hints.append(f"Other {src_dim_name} units with paths: {', '.join(compatible)}") - - else: - # Different dimensions — shouldn't normally reach here (would be DimensionMismatch) - hints.append("These units have different dimensions") - hints.append("Use check_dimensions() to verify compatibility before converting") - - # Limit to 3 hints - hints = hints[:3] - - return ConversionError( - error=f"No conversion path from '{from_unit_str}' to '{to_unit_str}'", - error_type="no_conversion_path", - parameter=None, - step=step, - got=src_dim_name, - expected=dst_dim_name, - hints=hints, - ) - - -def build_parse_error( - bad_expression: str, - error_message: str, - parameter: str, - step: int | None = None, -) -> ConversionError: - """Build a ConversionError for a malformed unit expression. - - Parameters - ---------- - bad_expression : str - The malformed unit string (e.g., "W/(m²*K" with unbalanced parens). - error_message : str - The parse error message from the parser. - parameter : str - Which parameter was bad (e.g., "from_unit", "to_unit"). - step : int | None - For multi-step chains, the 0-indexed step where the error occurred. - - Returns - ------- - ConversionError - Structured error with parse error details. - """ - hints = [ - f"Parse error: {error_message}", - "Check for unbalanced parentheses or invalid characters", - "Valid syntax: m/s, kg*m/s^2, W/(m²·K)", - ] - - return ConversionError( - error=f"Cannot parse unit expression: '{bad_expression}'", - error_type="parse_error", - parameter=parameter, - step=step, - hints=hints, - ) - - -def build_unknown_dimension_error(bad_dimension: str) -> ConversionError: - """Build a ConversionError for an unknown dimension filter. - - Parameters - ---------- - bad_dimension : str - The unrecognized dimension string. - - Returns - ------- - ConversionError - Structured error with similar dimension suggestions. - """ - from ucon.dimension import all_dimensions - - known = [d.name for d in all_dimensions()] - matches = get_close_matches(bad_dimension.lower(), [k.lower() for k in known], n=3, cutoff=0.6) - - # Map back to proper case - matches_proper = [] - for m in matches: - for k in known: - if k.lower() == m: - matches_proper.append(k) - break - - likely_fix = matches_proper[0] if len(matches_proper) == 1 else None - hints = [] - - if matches_proper and not likely_fix: - hints.append(f"Similar dimensions: {', '.join(matches_proper)}") - elif not matches_proper: - hints.append("Use list_dimensions() to see all available dimensions") - - return ConversionError( - error=f"Unknown dimension: '{bad_dimension}'", - error_type="unknown_unit", - parameter="dimension", - likely_fix=likely_fix, - hints=hints, - ) - - -__all__ = [ - "ConversionError", - "resolve_unit", - "build_unknown_unit_error", - "build_dimension_mismatch_error", - "build_no_path_error", - "build_parse_error", - "build_unknown_dimension_error", -] diff --git a/uv.lock b/uv.lock index 009dfa2..e68607e 100644 --- a/uv.lock +++ b/uv.lock @@ -40,20 +40,6 @@ 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.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "idna", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - [[package]] name = "astunparse" version = "1.6.3" @@ -67,15 +53,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/03/13dde6512ad7b4557eb792fbcf0c653af6076b81e5941d36ec61f7ce6028/astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8", size = 12732, upload-time = "2019-12-22T18:12:11.297Z" }, ] -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - [[package]] name = "babel" version = "2.14.0" @@ -160,100 +137,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "python_full_version >= '3.10' and 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/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, - { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, - { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, - { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, - { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, - { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, - { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, - { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.4" @@ -804,66 +687,6 @@ toml = [ { name = "tomli", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, ] -[[package]] -name = "cryptography" -version = "46.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -972,52 +795,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] -[[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", marker = "python_full_version >= '3.10'" }, - { name = "h11", marker = "python_full_version >= '3.10'" }, -] -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", marker = "python_full_version >= '3.10'" }, - { name = "certifi", marker = "python_full_version >= '3.10'" }, - { name = "httpcore", marker = "python_full_version >= '3.10'" }, - { name = "idna", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -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.10" @@ -1107,33 +884,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.10'" }, - { name = "jsonschema-specifications", marker = "python_full_version >= '3.10'" }, - { name = "referencing", marker = "python_full_version >= '3.10'" }, - { name = "rpds-py", marker = "python_full_version >= '3.10'" }, -] -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", marker = "python_full_version >= '3.10'" }, -] -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 = "markdown" version = "3.4.4" @@ -1362,31 +1112,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] -[[package]] -name = "mcp" -version = "1.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.10'" }, - { name = "httpx", marker = "python_full_version >= '3.10'" }, - { name = "httpx-sse", marker = "python_full_version >= '3.10'" }, - { name = "jsonschema", marker = "python_full_version >= '3.10'" }, - { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pydantic-settings", marker = "python_full_version >= '3.10'" }, - { name = "pyjwt", extra = ["crypto"], marker = "python_full_version >= '3.10'" }, - { name = "python-multipart", marker = "python_full_version >= '3.10'" }, - { name = "pywin32", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "python_full_version >= '3.10'" }, - { name = "starlette", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.10'" }, - { name = "uvicorn", marker = "python_full_version >= '3.10' and sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, -] - [[package]] name = "mergedeep" version = "1.3.4" @@ -1953,15 +1678,6 @@ 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.5.3" @@ -2382,20 +2098,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.10'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, -] - [[package]] name = "pygments" version = "2.17.2" @@ -2422,20 +2124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pyjwt" -version = "2.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography", marker = "python_full_version >= '3.10'" }, -] - [[package]] name = "pymdown-extensions" version = "10.2.1" @@ -2581,24 +2269,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, -] - [[package]] name = "pytz" version = "2025.2" @@ -2608,33 +2278,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/75/20/6cd04d636a4c83458ecbb7c8220c13786a1a80d3f5fb568df39310e73e98/pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c", size = 8766775, upload-time = "2025-07-14T20:12:55.029Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6c/94c10268bae5d0d0c6509bdfb5aa08882d11a9ccdf89ff1cde59a6161afb/pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd", size = 9594743, upload-time = "2025-07-14T20:12:57.59Z" }, - { url = "https://files.pythonhosted.org/packages/59/42/b86689aac0cdaee7ae1c58d464b0ff04ca909c19bb6502d4973cdd9f9544/pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b", size = 8760837, upload-time = "2025-07-14T20:12:59.59Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187, upload-time = "2025-07-14T20:13:01.419Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" }, -] - [[package]] name = "pyyaml" version = "6.0.1" @@ -2808,20 +2451,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.10'" }, - { name = "rpds-py", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and 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 = "regex" version = "2022.10.31" @@ -2958,128 +2587,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[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/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { 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" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -3089,32 +2596,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sse-starlette" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.10'" }, - { name = "starlette", marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, -] - -[[package]] -name = "starlette" -version = "0.52.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, -] - [[package]] name = "tomli" version = "2.0.1" @@ -3254,9 +2735,6 @@ docs = [ { name = "mkdocstrings", version = "0.30.1", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version == '3.9.*'" }, { name = "mkdocstrings", version = "1.0.3", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version >= '3.10'" }, ] -mcp = [ - { name = "mcp", marker = "python_full_version >= '3.10'" }, -] pydantic = [ { name = "pydantic", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, @@ -3276,7 +2754,6 @@ test = [ [package.metadata] requires-dist = [ { name = "coverage", extras = ["toml"], marker = "extra == 'test'", specifier = ">=5.5" }, - { name = "mcp", marker = "python_full_version >= '3.10' and extra == 'mcp'", specifier = ">=1.0" }, { name = "mkdocs-material", marker = "extra == 'docs'" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'" }, { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.0" }, @@ -3284,7 +2761,7 @@ requires-dist = [ { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "typing-extensions", marker = "python_full_version < '3.9'", specifier = ">=3.7.4" }, ] -provides-extras = ["test", "pydantic", "mcp", "docs"] +provides-extras = ["test", "pydantic", "docs"] [[package]] name = "urllib3" @@ -3323,20 +2800,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] -[[package]] -name = "uvicorn" -version = "0.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "h11", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, -] - [[package]] name = "watchdog" version = "3.0.0"